mirror of
https://github.com/home-assistant/core.git
synced 2026-02-28 13:01:35 +01:00
Compare commits
180 Commits
gen-dashbo
...
PIRUnoccup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
706127c9ea | ||
|
|
b163829970 | ||
|
|
5bd86ba600 | ||
|
|
70bc49479d | ||
|
|
81e0c105d6 | ||
|
|
527e2aec1f | ||
|
|
cd6661260c | ||
|
|
efa522cc73 | ||
|
|
f9bd1b3d30 | ||
|
|
4cfdb14714 | ||
|
|
6fb802e6b9 | ||
|
|
9b30fecb0c | ||
|
|
e77acc1002 | ||
|
|
07e8b780a2 | ||
|
|
e060395786 | ||
|
|
661b14dec5 | ||
|
|
b8e63b7ef6 | ||
|
|
fd78e35a86 | ||
|
|
db55dfe3c7 | ||
|
|
bda3121f98 | ||
|
|
fd4981f3e2 | ||
|
|
ae1bedd94a | ||
|
|
90b67f90fa | ||
|
|
9c821fb5f5 | ||
|
|
1f9691ace1 | ||
|
|
5331cd99c6 | ||
|
|
1c3f24c78f | ||
|
|
e179e74df3 | ||
|
|
98602bd311 | ||
|
|
5f01124c74 | ||
|
|
4b5368be8e | ||
|
|
6379014f13 | ||
|
|
aa640020be | ||
|
|
92f4e600d1 | ||
|
|
25a6b6fa65 | ||
|
|
3cbe1295f9 | ||
|
|
72581fb2b1 | ||
|
|
97c89590e0 | ||
|
|
b6ba86f3c1 | ||
|
|
cedc291872 | ||
|
|
1d30486f82 | ||
|
|
9f1b4c9035 | ||
|
|
80ebb34ad1 | ||
|
|
e0e11fd99d | ||
|
|
578a933f30 | ||
|
|
57493a1f69 | ||
|
|
3a4100fa94 | ||
|
|
0c1af1d613 | ||
|
|
4e46431798 | ||
|
|
bec66f49a2 | ||
|
|
4019768fa1 | ||
|
|
25d902fd3e | ||
|
|
30f006538d | ||
|
|
15b1fee42d | ||
|
|
d69b816459 | ||
|
|
bf79721e97 | ||
|
|
66a0b44284 | ||
|
|
8693294ea6 | ||
|
|
7a93eb779c | ||
|
|
14ac7927f1 | ||
|
|
b4674473d7 | ||
|
|
f01ece1d3d | ||
|
|
08160a41a6 | ||
|
|
e617698770 | ||
|
|
ee31bdf18b | ||
|
|
305b911c0d | ||
|
|
842abf78d2 | ||
|
|
134e8d1c1b | ||
|
|
733e90f747 | ||
|
|
6c92f7a864 | ||
|
|
f69b5b6e8f | ||
|
|
59e53ee7b7 | ||
|
|
62e1b0118c | ||
|
|
b7e9066b9d | ||
|
|
2d6532b8ee | ||
|
|
ebd1f1b00f | ||
|
|
95a1ceb080 | ||
|
|
3f9e7d1dba | ||
|
|
eab80f78d9 | ||
|
|
aa9fdd56ec | ||
|
|
c727261f67 | ||
|
|
703c62aa74 | ||
|
|
6e1f90228b | ||
|
|
3be089d2a5 | ||
|
|
692d3d35cc | ||
|
|
c52cb8362e | ||
|
|
93ac215ab4 | ||
|
|
f9eb86b50a | ||
|
|
a7f9992a4e | ||
|
|
13fde0d135 | ||
|
|
5105c6c50f | ||
|
|
af152ebe50 | ||
|
|
dea4452e42 | ||
|
|
af07631d83 | ||
|
|
d2ca00ca53 | ||
|
|
bb2f7bdfc4 | ||
|
|
b1379d9153 | ||
|
|
ea4b286659 | ||
|
|
2d00cb9a29 | ||
|
|
2ef1a20ae4 | ||
|
|
95defddfff | ||
|
|
009bdd91cc | ||
|
|
63bbead41e | ||
|
|
2c9a96b62a | ||
|
|
ace7fad62a | ||
|
|
3c73cc8bad | ||
|
|
83c41c265d | ||
|
|
c8bc5618dc | ||
|
|
60d770f265 | ||
|
|
6f4b9dcad7 | ||
|
|
1bba31f7af | ||
|
|
4705e584b0 | ||
|
|
80bbe5df6a | ||
|
|
88c4d88e06 | ||
|
|
718f459026 | ||
|
|
5c3ddcff3e | ||
|
|
08acececb2 | ||
|
|
27d6ae2881 | ||
|
|
5c4d9f4ca4 | ||
|
|
9ece327881 | ||
|
|
1b0ef3f358 | ||
|
|
a5eca0614a | ||
|
|
7b2509fadb | ||
|
|
f6e0bc28f4 | ||
|
|
e87056408e | ||
|
|
c945f32989 | ||
|
|
8d37917d8b | ||
|
|
68cc2dff53 | ||
|
|
45babbca92 | ||
|
|
b56dcfb7e9 | ||
|
|
a56114d84a | ||
|
|
de8a26c5b0 | ||
|
|
48f39524c4 | ||
|
|
2b4ef312c3 | ||
|
|
b4d175b811 | ||
|
|
7ff6c2a421 | ||
|
|
cf0a438f32 | ||
|
|
9e1bfa3564 | ||
|
|
3c266183e1 | ||
|
|
5c5f5d064a | ||
|
|
fc18ec4588 | ||
|
|
3fd2fa27e7 | ||
|
|
cf637f8c2f | ||
|
|
228fca9f0c | ||
|
|
c5ce8998e2 | ||
|
|
a4204bf11e | ||
|
|
3e44d15fc1 | ||
|
|
4f07d8688c | ||
|
|
89fda1a4ae | ||
|
|
f678e7ef34 | ||
|
|
24e8208deb | ||
|
|
3c66a1b35d | ||
|
|
5a2299e8b6 | ||
|
|
8087953b90 | ||
|
|
77a15b44c9 | ||
|
|
2177b494b9 | ||
|
|
10497c2bf4 | ||
|
|
e7fd744941 | ||
|
|
b9bfbc9e98 | ||
|
|
ba6f1343cc | ||
|
|
7d673cd9c4 | ||
|
|
44bc11580d | ||
|
|
c23795fe14 | ||
|
|
bf6f9a011b | ||
|
|
1cdbe596fe | ||
|
|
a9d52bfbe7 | ||
|
|
6eed1f9961 | ||
|
|
149607ab17 | ||
|
|
279b5be357 | ||
|
|
82b93e788b | ||
|
|
555813f84f | ||
|
|
ecf1b4e591 | ||
|
|
e17a9f12a1 | ||
|
|
e8f05f5291 | ||
|
|
a5a76e9268 | ||
|
|
edc3fb47b2 | ||
|
|
f1e514a70a | ||
|
|
5632baca5b | ||
|
|
78f9bad706 | ||
|
|
3fdaaecd0f |
@@ -60,7 +60,13 @@
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"[json][jsonc][yaml]": {
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"json.schemas": [
|
||||
|
||||
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -225,7 +225,7 @@ jobs:
|
||||
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -530,7 +530,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -254,7 +254,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@564dda4cfa5e96aafdc4a5696c4bf7b46baae5ac # v1.1.0
|
||||
uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
@@ -287,6 +287,7 @@ homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
homeassistant.components.integration.*
|
||||
homeassistant.components.intelliclima.*
|
||||
homeassistant.components.intent.*
|
||||
homeassistant.components.intent_script.*
|
||||
homeassistant.components.ios.*
|
||||
@@ -363,7 +364,6 @@ homeassistant.components.my.*
|
||||
homeassistant.components.mysensors.*
|
||||
homeassistant.components.myuplink.*
|
||||
homeassistant.components.nam.*
|
||||
homeassistant.components.nanoleaf.*
|
||||
homeassistant.components.nasweb.*
|
||||
homeassistant.components.neato.*
|
||||
homeassistant.components.nest.*
|
||||
|
||||
14
CODEOWNERS
generated
14
CODEOWNERS
generated
@@ -15,7 +15,7 @@
|
||||
.yamllint @home-assistant/core
|
||||
pyproject.toml @home-assistant/core
|
||||
requirements_test.txt @home-assistant/core
|
||||
/.devcontainer/ @home-assistant/core
|
||||
/.devcontainer/ @home-assistant/core @edenhaus
|
||||
/.github/ @home-assistant/core
|
||||
/.vscode/ @home-assistant/core
|
||||
/homeassistant/*.py @home-assistant/core
|
||||
@@ -672,6 +672,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/hdmi_cec/ @inytar
|
||||
/tests/components/hdmi_cec/ @inytar
|
||||
/homeassistant/components/heatmiser/ @andylockran
|
||||
/homeassistant/components/hegel/ @boazca
|
||||
/tests/components/hegel/ @boazca
|
||||
/homeassistant/components/heos/ @andrewsayre
|
||||
/tests/components/heos/ @andrewsayre
|
||||
/homeassistant/components/here_travel_time/ @eifinger
|
||||
@@ -715,8 +717,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/homekit_controller/ @Jc2k @bdraco
|
||||
/homeassistant/components/homematic/ @pvizeli
|
||||
/tests/components/homematic/ @pvizeli
|
||||
/homeassistant/components/homematicip_cloud/ @hahn-th
|
||||
/tests/components/homematicip_cloud/ @hahn-th
|
||||
/homeassistant/components/homematicip_cloud/ @hahn-th @lackas
|
||||
/tests/components/homematicip_cloud/ @hahn-th @lackas
|
||||
/homeassistant/components/homewizard/ @DCSBL
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
@@ -802,6 +804,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/insteon/ @teharris1
|
||||
/homeassistant/components/integration/ @dgomes
|
||||
/tests/components/integration/ @dgomes
|
||||
/homeassistant/components/intelliclima/ @dvdinth
|
||||
/tests/components/intelliclima/ @dvdinth
|
||||
/homeassistant/components/intellifire/ @jeeftor
|
||||
/tests/components/intellifire/ @jeeftor
|
||||
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
|
||||
@@ -1078,8 +1082,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nam/ @bieniu
|
||||
/homeassistant/components/namecheapdns/ @tr4nt0r
|
||||
/tests/components/namecheapdns/ @tr4nt0r
|
||||
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
|
||||
/tests/components/nanoleaf/ @milanmeu @joostlek
|
||||
/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
|
||||
/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
|
||||
/homeassistant/components/nasweb/ @nasWebio
|
||||
/tests/components/nasweb/ @nasWebio
|
||||
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
|
||||
@@ -29,3 +29,24 @@ COUNTRY_DOMAINS = {
|
||||
|
||||
CATEGORY_SENSORS = "sensors"
|
||||
CATEGORY_NOTIFICATIONS = "notifications"
|
||||
|
||||
# Map service translation keys to Alexa API
|
||||
INFO_SKILLS_MAPPING = {
|
||||
"calendar_today": "Alexa.Calendar.PlayToday",
|
||||
"calendar_tomorrow": "Alexa.Calendar.PlayTomorrow",
|
||||
"calendar_next": "Alexa.Calendar.PlayNext",
|
||||
"date": "Alexa.Date.Play",
|
||||
"time": "Alexa.Time.Play",
|
||||
"national_news": "Alexa.News.NationalNews",
|
||||
"flash_briefing": "Alexa.FlashBriefing.Play",
|
||||
"traffic": "Alexa.Traffic.Play",
|
||||
"weather": "Alexa.Weather.Play",
|
||||
"cleanup": "Alexa.CleanUp.Play",
|
||||
"good_morning": "Alexa.GoodMorning.Play",
|
||||
"sing_song": "Alexa.SingASong.Play",
|
||||
"fun_fact": "Alexa.FunFact.Play",
|
||||
"tell_joke": "Alexa.Joke.Play",
|
||||
"tell_story": "Alexa.TellStory.Play",
|
||||
"im_home": "Alexa.ImHome.Play",
|
||||
"goodnight": "Alexa.GoodNight.Play",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"services": {
|
||||
"send_info_skill": {
|
||||
"service": "mdi:information"
|
||||
},
|
||||
"send_sound": {
|
||||
"service": "mdi:cast-audio"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.1.3"]
|
||||
"requirements": ["aioamazondevices==12.0.0"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Support for services."""
|
||||
|
||||
from aioamazondevices.const.metadata import ALEXA_INFO_SKILLS
|
||||
from aioamazondevices.const.sounds import SOUNDS_LIST
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -9,13 +10,15 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, INFO_SKILLS_MAPPING
|
||||
from .coordinator import AmazonConfigEntry
|
||||
|
||||
ATTR_TEXT_COMMAND = "text_command"
|
||||
ATTR_SOUND = "sound"
|
||||
ATTR_INFO_SKILL = "info_skill"
|
||||
SERVICE_TEXT_COMMAND = "send_text_command"
|
||||
SERVICE_SOUND_NOTIFICATION = "send_sound"
|
||||
SERVICE_INFO_SKILL = "send_info_skill"
|
||||
|
||||
SCHEMA_SOUND_SERVICE = vol.Schema(
|
||||
{
|
||||
@@ -29,6 +32,12 @@ SCHEMA_CUSTOM_COMMAND = vol.Schema(
|
||||
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
SCHEMA_INFO_SKILL = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_INFO_SKILL): cv.string,
|
||||
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -86,6 +95,17 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
|
||||
await coordinator.api.call_alexa_text_command(
|
||||
coordinator.data[device.serial_number], value
|
||||
)
|
||||
elif attribute == ATTR_INFO_SKILL:
|
||||
info_skill = INFO_SKILLS_MAPPING.get(value)
|
||||
if info_skill not in ALEXA_INFO_SKILLS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_info_skill_value",
|
||||
translation_placeholders={"info_skill": value},
|
||||
)
|
||||
await coordinator.api.call_alexa_info_skill(
|
||||
coordinator.data[device.serial_number], value
|
||||
)
|
||||
|
||||
|
||||
async def async_send_sound_notification(call: ServiceCall) -> None:
|
||||
@@ -98,6 +118,11 @@ async def async_send_text_command(call: ServiceCall) -> None:
|
||||
await _async_execute_action(call, ATTR_TEXT_COMMAND)
|
||||
|
||||
|
||||
async def async_send_info_skill(call: ServiceCall) -> None:
|
||||
"""Send an info skill command to a AmazonDevice."""
|
||||
await _async_execute_action(call, ATTR_INFO_SKILL)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the Amazon Devices integration."""
|
||||
@@ -112,5 +137,10 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
async_send_text_command,
|
||||
SCHEMA_CUSTOM_COMMAND,
|
||||
),
|
||||
(
|
||||
SERVICE_INFO_SKILL,
|
||||
async_send_info_skill,
|
||||
SCHEMA_INFO_SKILL,
|
||||
),
|
||||
):
|
||||
hass.services.async_register(DOMAIN, service_name, method, schema=schema)
|
||||
|
||||
@@ -67,3 +67,36 @@ send_sound:
|
||||
- squeaky_12
|
||||
- zap_01
|
||||
translation_key: sound
|
||||
|
||||
send_info_skill:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: alexa_devices
|
||||
info_skill:
|
||||
required: true
|
||||
example: date
|
||||
default: date
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- calendar_today
|
||||
- calendar_tomorrow
|
||||
- calendar_next
|
||||
- date
|
||||
- time
|
||||
- national_news
|
||||
- flash_briefing
|
||||
- traffic
|
||||
- weather
|
||||
- cleanup
|
||||
- good_morning
|
||||
- sing_song
|
||||
- fun_fact
|
||||
- tell_joke
|
||||
- tell_story
|
||||
- im_home
|
||||
- goodnight
|
||||
translation_key: info_skill
|
||||
|
||||
@@ -102,11 +102,35 @@
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID specified: {device_id}"
|
||||
},
|
||||
"invalid_info_skill_value": {
|
||||
"message": "Invalid info skill {info_skill} specified"
|
||||
},
|
||||
"invalid_sound_value": {
|
||||
"message": "Invalid sound {sound} specified"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"info_skill": {
|
||||
"options": {
|
||||
"calendar_next": "Calendar: Next event",
|
||||
"calendar_today": "Calendar: Today's Calendar",
|
||||
"calendar_tomorrow": "Calendar: Tomorrow's Calendar",
|
||||
"cleanup": "Encourage me to clean up",
|
||||
"date": "Date",
|
||||
"flash_briefing": "Flash Briefing",
|
||||
"fun_fact": "Tell me a fun fact",
|
||||
"good_morning": "Good morning",
|
||||
"goodnight": "Wish me a good night",
|
||||
"im_home": "Welcome me home",
|
||||
"national_news": "National News",
|
||||
"sing_song": "Sing a song",
|
||||
"tell_joke": "Tell me a joke",
|
||||
"tell_story": "Tell me a story",
|
||||
"time": "Time",
|
||||
"traffic": "Traffic",
|
||||
"weather": "Weather"
|
||||
}
|
||||
},
|
||||
"sound": {
|
||||
"options": {
|
||||
"air_horn_03": "Air horn",
|
||||
@@ -154,6 +178,20 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"send_info_skill": {
|
||||
"description": "Sends an info skill command to a device",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "[%key:component::alexa_devices::common::device_id_description%]",
|
||||
"name": "Device"
|
||||
},
|
||||
"info_skill": {
|
||||
"description": "The info skill command to send.",
|
||||
"name": "Alexa info skill command"
|
||||
}
|
||||
},
|
||||
"name": "Send info skill command"
|
||||
},
|
||||
"send_sound": {
|
||||
"description": "Sends a sound to a device",
|
||||
"fields": {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from amberelectric.models.channel import ChannelType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@@ -13,6 +12,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import service
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
@@ -37,23 +37,6 @@ GET_FORECASTS_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry:
|
||||
"""Get the Amber config entry."""
|
||||
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": config_entry_id},
|
||||
)
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": entry.title},
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]:
|
||||
"""Return an array of forecasts."""
|
||||
results: list[JsonValueType] = []
|
||||
@@ -109,7 +92,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
|
||||
channel_type = call.data[ATTR_CHANNEL_TYPE]
|
||||
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
|
||||
entry: AmberConfigEntry = service.async_get_config_entry(
|
||||
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
)
|
||||
coordinator = entry.runtime_data
|
||||
forecasts = get_forecasts(channel_type, coordinator.data)
|
||||
return {"forecasts": forecasts}
|
||||
|
||||
@@ -25,12 +25,6 @@
|
||||
"exceptions": {
|
||||
"channel_not_found": {
|
||||
"message": "There is no {channel_type} channel at this site."
|
||||
},
|
||||
"integration_not_found": {
|
||||
"message": "Config entry \"{target}\" not found in registry."
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
|
||||
@@ -73,31 +73,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
started = False
|
||||
|
||||
async def _async_handle_labs_update(
|
||||
event: Event[labs.EventLabsUpdatedData],
|
||||
event_data: labs.EventLabsUpdatedData,
|
||||
) -> None:
|
||||
"""Handle labs feature toggle."""
|
||||
await analytics.save_preferences({ATTR_SNAPSHOTS: event.data["enabled"]})
|
||||
await analytics.save_preferences({ATTR_SNAPSHOTS: event_data["enabled"]})
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
@callback
|
||||
def _async_labs_event_filter(event_data: labs.EventLabsUpdatedData) -> bool:
|
||||
"""Filter labs events for this integration's snapshot feature."""
|
||||
return (
|
||||
event_data["domain"] == DOMAIN
|
||||
and event_data["preview_feature"] == LABS_SNAPSHOT_FEATURE
|
||||
)
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
|
||||
hass.bus.async_listen(
|
||||
labs.EVENT_LABS_UPDATED,
|
||||
_async_handle_labs_update,
|
||||
event_filter=_async_labs_event_filter,
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
|
||||
@@ -491,22 +491,24 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"role": "user",
|
||||
"content": "Where are the following coordinates located: "
|
||||
f"({zone_home.attributes[ATTR_LATITUDE]},"
|
||||
f" {zone_home.attributes[ATTR_LONGITUDE]})? Please respond "
|
||||
"only with a JSON object using the following schema:\n"
|
||||
f"{convert(location_schema)}",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "{", # hints the model to skip any preamble
|
||||
},
|
||||
f" {zone_home.attributes[ATTR_LONGITUDE]})?",
|
||||
}
|
||||
],
|
||||
max_tokens=cast(int, DEFAULT[CONF_MAX_TOKENS]),
|
||||
output_config={
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"schema": {
|
||||
**convert(location_schema),
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
_LOGGER.debug("Model response: %s", response.content)
|
||||
location_data = location_schema(
|
||||
json.loads(
|
||||
"{"
|
||||
+ "".join(
|
||||
"".join(
|
||||
block.text
|
||||
for block in response.content
|
||||
if isinstance(block, anthropic.types.TextBlock)
|
||||
|
||||
@@ -56,6 +56,15 @@ NON_ADAPTIVE_THINKING_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-0",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
"claude-3-opus",
|
||||
|
||||
@@ -20,6 +20,7 @@ from anthropic.types import (
|
||||
DocumentBlockParam,
|
||||
ImageBlockParam,
|
||||
InputJSONDelta,
|
||||
JSONOutputFormatParam,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
@@ -94,6 +95,7 @@ from .const import (
|
||||
MIN_THINKING_BUDGET,
|
||||
NON_ADAPTIVE_THINKING_MODELS,
|
||||
NON_THINKING_MODELS,
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
@@ -697,8 +699,25 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
)
|
||||
|
||||
if structure and structure_name:
|
||||
structure_name = slugify(structure_name)
|
||||
if model_args["thinking"]["type"] == "disabled":
|
||||
if not model.startswith(tuple(UNSUPPORTED_STRUCTURED_OUTPUT_MODELS)):
|
||||
# Native structured output for those models who support it.
|
||||
structure_name = None
|
||||
model_args.setdefault("output_config", OutputConfigParam())[
|
||||
"format"
|
||||
] = JSONOutputFormatParam(
|
||||
type="json_schema",
|
||||
schema={
|
||||
**convert(
|
||||
structure,
|
||||
custom_serializer=chat_log.llm_api.custom_serializer
|
||||
if chat_log.llm_api
|
||||
else llm.selector_serializer,
|
||||
),
|
||||
"additionalProperties": False,
|
||||
},
|
||||
)
|
||||
elif model_args["thinking"]["type"] == "disabled":
|
||||
structure_name = slugify(structure_name)
|
||||
if not tools:
|
||||
# Simplest case: no tools and no extended thinking
|
||||
# Add a tool and force its use
|
||||
@@ -718,6 +737,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
# force tool use or disable text responses, so we add a hint to the
|
||||
# system prompt instead. With extended thinking, the model should be
|
||||
# smart enough to use the tool.
|
||||
structure_name = slugify(structure_name)
|
||||
model_args["tool_choice"] = ToolChoiceAutoParam(
|
||||
type="auto",
|
||||
)
|
||||
@@ -725,22 +745,24 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
model_args["system"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=f"Claude MUST use the '{structure_name}' tool to provide the final answer instead of plain text.",
|
||||
text=f"Claude MUST use the '{structure_name}' tool to provide "
|
||||
"the final answer instead of plain text.",
|
||||
)
|
||||
)
|
||||
|
||||
tools.append(
|
||||
ToolParam(
|
||||
name=structure_name,
|
||||
description="Use this tool to reply to the user",
|
||||
input_schema=convert(
|
||||
structure,
|
||||
custom_serializer=chat_log.llm_api.custom_serializer
|
||||
if chat_log.llm_api
|
||||
else llm.selector_serializer,
|
||||
),
|
||||
if structure_name:
|
||||
tools.append(
|
||||
ToolParam(
|
||||
name=structure_name,
|
||||
description="Use this tool to reply to the user",
|
||||
input_schema=convert(
|
||||
structure,
|
||||
custom_serializer=chat_log.llm_api.custom_serializer
|
||||
if chat_log.llm_api
|
||||
else llm.selector_serializer,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
@@ -761,7 +783,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
_transform_stream(
|
||||
chat_log,
|
||||
stream,
|
||||
output_tool=structure_name if structure else None,
|
||||
output_tool=structure_name or None,
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.16"]
|
||||
"requirements": ["py-aosmith==1.0.17"]
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aranet",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aranet4==2.5.1"]
|
||||
"requirements": ["aranet4==2.6.0"]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"title": "Set up one-time password delivered by notify component"
|
||||
},
|
||||
"setup": {
|
||||
"data": {
|
||||
"code": "Code"
|
||||
},
|
||||
"description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:",
|
||||
"title": "Verify setup"
|
||||
}
|
||||
@@ -42,6 +45,9 @@
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"code": "Code"
|
||||
},
|
||||
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator]({google_authenticator_url}) or [Authy]({authy_url}).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.",
|
||||
"title": "Set up two-factor authentication using TOTP"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
|
||||
from homeassistant.components.labs import async_listen as async_labs_listen
|
||||
from homeassistant.components.labs import async_subscribe_preview_feature
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
@@ -386,14 +386,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
schema=vol.Schema({vol.Optional(CONF_ID): str}),
|
||||
)
|
||||
|
||||
@callback
|
||||
def new_triggers_conditions_listener() -> None:
|
||||
async def new_triggers_conditions_listener(
|
||||
_event_data: labs.EventLabsUpdatedData,
|
||||
) -> None:
|
||||
"""Handle new_triggers_conditions flag change."""
|
||||
hass.async_create_task(
|
||||
reload_helper.execute_service(ServiceCall(hass, DOMAIN, SERVICE_RELOAD))
|
||||
)
|
||||
await reload_helper.execute_service(ServiceCall(hass, DOMAIN, SERVICE_RELOAD))
|
||||
|
||||
async_labs_listen(
|
||||
async_subscribe_preview_feature(
|
||||
hass,
|
||||
DOMAIN,
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG,
|
||||
|
||||
@@ -297,14 +297,14 @@ class S3BackupAgent(BackupAgent):
|
||||
return self._backup_cache
|
||||
|
||||
backups = {}
|
||||
response = await self._client.list_objects_v2(Bucket=self._bucket)
|
||||
|
||||
# Filter for metadata files only
|
||||
metadata_files = [
|
||||
obj
|
||||
for obj in response.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
]
|
||||
paginator = self._client.get_paginator("list_objects_v2")
|
||||
metadata_files: list[dict[str, Any]] = []
|
||||
async for page in paginator.paginate(Bucket=self._bucket):
|
||||
metadata_files.extend(
|
||||
obj
|
||||
for obj in page.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
)
|
||||
|
||||
for metadata_file in metadata_files:
|
||||
try:
|
||||
|
||||
@@ -8,11 +8,10 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME
|
||||
@@ -41,21 +40,10 @@ SET_DATE_TIME_SCHEMA = vol.Schema(
|
||||
|
||||
async def async_set_panel_date(call: ServiceCall) -> None:
|
||||
"""Set the date and time on a bosch alarm panel."""
|
||||
config_entry: BoschAlarmConfigEntry | None
|
||||
value: dt.datetime = call.data.get(ATTR_DATETIME, dt_util.now())
|
||||
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": entry_id},
|
||||
)
|
||||
if config_entry.state is not ConfigEntryState.LOADED:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": config_entry.title},
|
||||
)
|
||||
config_entry: BoschAlarmConfigEntry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
)
|
||||
panel = config_entry.runtime_data
|
||||
try:
|
||||
await panel.set_panel_date(value)
|
||||
|
||||
@@ -155,12 +155,6 @@
|
||||
"incorrect_door_state": {
|
||||
"message": "Door cannot be manipulated while it is momentarily unlocked."
|
||||
},
|
||||
"integration_not_found": {
|
||||
"message": "Integration \"{target}\" not found in registry."
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
},
|
||||
"unknown_error": {
|
||||
"message": "An unknown error occurred while setting the date and time on \"{target}\"."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Actions for Bring! integration."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bring_api import (
|
||||
ActivityType,
|
||||
BringAuthException,
|
||||
@@ -13,7 +11,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.event import ATTR_EVENT_TYPE
|
||||
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
@@ -46,19 +43,6 @@ SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
if TYPE_CHECKING:
|
||||
assert entry
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_loaded",
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Bring! integration."""
|
||||
@@ -78,7 +62,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
ATTR_ENTITY_ID: call.data[ATTR_ENTITY_ID],
|
||||
},
|
||||
)
|
||||
config_entry = get_config_entry(hass, entity.config_entry_id)
|
||||
config_entry: BringConfigEntry = service.async_get_config_entry(
|
||||
hass, DOMAIN, entity.config_entry_id
|
||||
)
|
||||
|
||||
coordinator = config_entry.runtime_data.data
|
||||
|
||||
|
||||
@@ -124,10 +124,6 @@
|
||||
"entity_not_found": {
|
||||
"message": "Failed to send reaction for Bring! — Unknown entity {entity_id}"
|
||||
},
|
||||
|
||||
"entry_not_loaded": {
|
||||
"message": "The account associated with this Bring! list is either not loaded or disabled in Home Assistant."
|
||||
},
|
||||
"notify_missing_argument": {
|
||||
"message": "This action requires field {field}, please enter a valid value for {field}"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Final
|
||||
|
||||
from bsblan import BSBLANError
|
||||
from bsblan import BSBLANError, get_hvac_action_category
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
@@ -13,6 +13,7 @@ from homeassistant.components.climate import (
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
@@ -128,6 +129,15 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value)
|
||||
return try_parse_enum(HVACMode, hvac_mode_value)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current running hvac action."""
|
||||
action = self.coordinator.data.state.hvac_action
|
||||
if not action or not isinstance(action.value, int):
|
||||
return None
|
||||
category = get_hvac_action_category(action.value)
|
||||
return HVACAction(category.name.lower())
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==4.1.0"],
|
||||
"requirements": ["python-bsblan==4.2.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -9,10 +9,11 @@ from bsblan import BSBLANError, SetHotWaterParam
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_ECO,
|
||||
STATE_OFF,
|
||||
STATE_PERFORMANCE,
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, STATE_ON
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
@@ -24,14 +25,16 @@ from .entity import BSBLanDualCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# Mapping between BSBLan and HA operation modes
|
||||
OPERATION_MODES = {
|
||||
"Eco": STATE_ECO, # Energy saving mode
|
||||
"Off": STATE_OFF, # Protection mode
|
||||
"On": STATE_ON, # Continuous comfort mode
|
||||
# Mapping between BSBLan operating mode values and HA operation modes
|
||||
BSBLAN_TO_HA_OPERATION_MODE: dict[int, str] = {
|
||||
0: STATE_OFF, # Protection mode
|
||||
1: STATE_PERFORMANCE, # Continuous comfort mode
|
||||
2: STATE_ECO, # Eco/automatic mode
|
||||
}
|
||||
|
||||
OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()}
|
||||
HA_TO_BSBLAN_OPERATION_MODE: dict[str, int] = {
|
||||
v: k for k, v in BSBLAN_TO_HA_OPERATION_MODE.items()
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -63,13 +66,14 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
_attr_supported_features = (
|
||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||
| WaterHeaterEntityFeature.ON_OFF
|
||||
)
|
||||
|
||||
def __init__(self, data: BSBLanData) -> None:
|
||||
"""Initialize BSBLAN water heater."""
|
||||
super().__init__(data.fast_coordinator, data.slow_coordinator, data)
|
||||
self._attr_unique_id = format_mac(data.device.MAC)
|
||||
self._attr_operation_list = list(OPERATION_MODES_REVERSE.keys())
|
||||
self._attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
|
||||
|
||||
# Set temperature unit
|
||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||
@@ -110,8 +114,11 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
"""Return current operation."""
|
||||
if self.coordinator.data.dhw.operating_mode is None:
|
||||
return None
|
||||
current_mode = self.coordinator.data.dhw.operating_mode.desc
|
||||
return OPERATION_MODES.get(current_mode)
|
||||
# The operating_mode.value is an integer (0=Off, 1=On, 2=Eco)
|
||||
current_mode_value = self.coordinator.data.dhw.operating_mode.value
|
||||
if isinstance(current_mode_value, int):
|
||||
return BSBLAN_TO_HA_OPERATION_MODE.get(current_mode_value)
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
@@ -144,10 +151,12 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Set new operation mode."""
|
||||
bsblan_mode = OPERATION_MODES_REVERSE.get(operation_mode)
|
||||
# Base class validates operation_mode is in operation_list before calling
|
||||
bsblan_mode = HA_TO_BSBLAN_OPERATION_MODE[operation_mode]
|
||||
try:
|
||||
# Send numeric value as string - BSB-LAN API expects numeric mode values
|
||||
await self.coordinator.client.set_hot_water(
|
||||
SetHotWaterParam(operating_mode=bsblan_mode)
|
||||
SetHotWaterParam(operating_mode=str(bsblan_mode))
|
||||
)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -156,3 +165,11 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the water heater on."""
|
||||
await self.async_set_operation_mode(STATE_PERFORMANCE)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the water heater off."""
|
||||
await self.async_set_operation_mode(STATE_OFF)
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiostreammagic==2.11.0"],
|
||||
"requirements": ["aiostreammagic==2.12.1"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
"secret_access_key": "Secret access key"
|
||||
},
|
||||
"data_description": {
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2 (this is your Account ID)",
|
||||
"access_key_id": "Access key ID to connect to Cloudflare R2",
|
||||
"bucket": "Bucket must already exist and be writable by the provided credentials.",
|
||||
"endpoint_url": "Cloudflare R2 S3-compatible endpoint.",
|
||||
"prefix": "Optional folder path inside the bucket. Example: backups/homeassistant",
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Docs]({auth_docs_url})"
|
||||
"secret_access_key": "Secret access key to connect to Cloudflare R2. See [Cloudflare documentation]({auth_docs_url})"
|
||||
},
|
||||
"title": "Add Cloudflare R2 bucket"
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ class ComelitAlarmEntity(
|
||||
"""Update state after action."""
|
||||
self._area.human_status = area_state
|
||||
self._area.armed = armed
|
||||
await self.async_update_ha_state()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
|
||||
@@ -70,6 +70,10 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
|
||||
_attr_name = None
|
||||
|
||||
# TODO(2026.7.0): When support for unknown fan speeds is removed, delete this variable.
|
||||
# Holds unknown fan speeds we have already warned about.
|
||||
warned_unknown_fan_speeds: set[str] = set()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CoolmasterDataUpdateCoordinator,
|
||||
@@ -125,8 +129,20 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
|
||||
# Normalize to lowercase for lookup, and pass unknown values through.
|
||||
return CM_TO_HA_FAN.get(self._unit.fan_speed.lower(), self._unit.fan_speed)
|
||||
# Normalize to lowercase for lookup, and pass unknown lowercase values through.
|
||||
fan_speed_lower = self._unit.fan_speed.lower()
|
||||
if fan_speed_lower not in CM_TO_HA_FAN:
|
||||
# TODO(2026.7.0): Stop supporting unknown fan speeds.
|
||||
if fan_speed_lower not in CoolmasterClimate.warned_unknown_fan_speeds:
|
||||
CoolmasterClimate.warned_unknown_fan_speeds.add(fan_speed_lower)
|
||||
_LOGGER.warning(
|
||||
"Detected unknown fan speed value from HVAC unit: %s. "
|
||||
"Support for unknown fan speeds will be removed in 2026.7.0",
|
||||
fan_speed_lower,
|
||||
)
|
||||
return fan_speed_lower
|
||||
|
||||
return CM_TO_HA_FAN[fan_speed_lower]
|
||||
|
||||
@property
|
||||
def fan_modes(self):
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["HomeControl", "Mydevolo", "MprmRest", "MprmWebsocket", "Mprm"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["devolo-home-control-api==0.19.0"],
|
||||
"zeroconf": ["_dvl-deviceapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional 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: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
The information provided by the discovery is not used for more than displaying the integration in the UI.
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not define custom icons. All entities use device class icons.
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
No configuration besides credentials.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't have any cases where raising an issue is needed.
|
||||
stale-devices: done
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not use a web session.
|
||||
strict-typing: done
|
||||
@@ -10,7 +10,6 @@ from typing import Final
|
||||
from easyenergy import Electricity, Gas, VatOption
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
@@ -19,7 +18,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers import selector, service
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -88,28 +87,9 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp
|
||||
|
||||
def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
|
||||
"""Get the coordinator from the entry."""
|
||||
entry_id: str = call.data[ATTR_CONFIG_ENTRY]
|
||||
entry: EasyEnergyConfigEntry | None = call.hass.config_entries.async_get_entry(
|
||||
entry_id
|
||||
entry: EasyEnergyConfigEntry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY]
|
||||
)
|
||||
|
||||
if not entry:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_config_entry",
|
||||
translation_placeholders={
|
||||
"config_entry": entry_id,
|
||||
},
|
||||
)
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unloaded_config_entry",
|
||||
translation_placeholders={
|
||||
"config_entry": entry.title,
|
||||
},
|
||||
)
|
||||
|
||||
return entry.runtime_data
|
||||
|
||||
|
||||
|
||||
@@ -44,14 +44,8 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_config_entry": {
|
||||
"message": "Invalid config entry provided. Got {config_entry}"
|
||||
},
|
||||
"invalid_date": {
|
||||
"message": "Invalid date provided. Got {date}"
|
||||
},
|
||||
"unloaded_config_entry": {
|
||||
"message": "Invalid config entry provided. {config_entry} is not loaded."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
@@ -15,6 +16,12 @@ from homeassistant.helpers.selector import (
|
||||
from . import dongle
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
|
||||
|
||||
MANUAL_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the enOcean config flows."""
|
||||
@@ -49,17 +56,14 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Propose a list of detected dongles."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if user_input[CONF_DEVICE] == self.MANUAL_PATH_VALUE:
|
||||
return await self.async_step_manual()
|
||||
if await self.validate_enocean_conf(user_input):
|
||||
return self.create_enocean_entry(user_input)
|
||||
errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH}
|
||||
return await self.async_step_manual(user_input)
|
||||
|
||||
devices = await self.hass.async_add_executor_job(dongle.detect)
|
||||
if len(devices) == 0:
|
||||
return await self.async_step_manual(user_input)
|
||||
return await self.async_step_manual()
|
||||
devices.append(self.MANUAL_PATH_VALUE)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -75,26 +79,21 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Request manual USB dongle path."""
|
||||
default_value = None
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if await self.validate_enocean_conf(user_input):
|
||||
return self.create_enocean_entry(user_input)
|
||||
default_value = user_input[CONF_DEVICE]
|
||||
errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="manual",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_DEVICE, default=default_value): str}
|
||||
),
|
||||
data_schema=self.add_suggested_values_to_schema(MANUAL_SCHEMA, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.14.0",
|
||||
"aioesphomeapi==44.0.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.6.0"
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
|
||||
RestoreSensor,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfMass
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -47,6 +48,7 @@ class EufyLifeSensorEntity(SensorEntity):
|
||||
"""Representation of an EufyLife sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, data: EufyLifeData) -> None:
|
||||
"""Initialize the weight sensor entity."""
|
||||
|
||||
@@ -65,10 +65,10 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
|
||||
super().__init__(avm_wrapper, device_friendly_name)
|
||||
ImageEntity.__init__(self, hass)
|
||||
|
||||
async def _fetch_image(self) -> bytes:
|
||||
def _fetch_image(self) -> bytes:
|
||||
"""Fetch the QR code from the Fritz!Box."""
|
||||
qr_stream: BytesIO = await self.hass.async_add_executor_job(
|
||||
self._avm_wrapper.fritz_guest_wifi.get_wifi_qr_code, "png"
|
||||
qr_stream: BytesIO = self._avm_wrapper.fritz_guest_wifi.get_wifi_qr_code(
|
||||
"png", border=2
|
||||
)
|
||||
qr_bytes = qr_stream.getvalue()
|
||||
_LOGGER.debug("fetched %s bytes", len(qr_bytes))
|
||||
@@ -77,13 +77,15 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Fetch and set initial data and state."""
|
||||
self._current_qr_bytes = await self._fetch_image()
|
||||
self._current_qr_bytes = await self.hass.async_add_executor_job(
|
||||
self._fetch_image
|
||||
)
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the image entity data."""
|
||||
try:
|
||||
qr_bytes = await self._fetch_image()
|
||||
qr_bytes = await self.hass.async_add_executor_job(self._fetch_image)
|
||||
except RequestException:
|
||||
self._current_qr_bytes = None
|
||||
self._attr_image_last_updated = None
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"pitch": "Default pitch of the voice",
|
||||
"profiles": "Default audio profiles",
|
||||
"speed": "Default rate/speed of the voice",
|
||||
"stt_model": "Speech-to-Text model",
|
||||
"stt_model": "Speech-to-text model",
|
||||
"text_type": "Default text type",
|
||||
"voice": "Default voice name (overrides language and gender)"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ class GoogleGenerativeAITaskEntity(
|
||||
) -> ai_task.GenDataTaskResult:
|
||||
"""Handle a generate data task."""
|
||||
await self._async_handle_chat_log(
|
||||
chat_log, task.structure, default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS
|
||||
chat_log,
|
||||
task.structure,
|
||||
default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS,
|
||||
max_iterations=1000,
|
||||
)
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
|
||||
@@ -486,6 +486,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
chat_log: conversation.ChatLog,
|
||||
structure: vol.Schema | None = None,
|
||||
default_max_tokens: int | None = None,
|
||||
max_iterations: int = MAX_TOOL_ITERATIONS,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
@@ -602,7 +603,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
)
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
for _iteration in range(max_iterations):
|
||||
try:
|
||||
chat_response_generator = await chat.send_message_stream(
|
||||
message=chat_request
|
||||
|
||||
@@ -18,8 +18,8 @@ from homeassistant.core import (
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN, UPLOAD_SCOPE
|
||||
from .coordinator import GooglePhotosConfigEntry
|
||||
@@ -80,15 +80,10 @@ def _read_file_contents(
|
||||
|
||||
async def _async_handle_upload(call: ServiceCall) -> ServiceResponse:
|
||||
"""Generate content from text and optionally images."""
|
||||
config_entry: GooglePhotosConfigEntry | None = (
|
||||
call.hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
|
||||
config_entry: GooglePhotosConfigEntry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID]
|
||||
)
|
||||
if not config_entry:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": DOMAIN},
|
||||
)
|
||||
|
||||
scopes = config_entry.data["token"]["scope"].split(" ")
|
||||
if UPLOAD_SCOPE not in scopes:
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -62,18 +62,12 @@
|
||||
"filename_is_not_image": {
|
||||
"message": "`{filename}` is not an image"
|
||||
},
|
||||
"integration_not_found": {
|
||||
"message": "Integration \"{target}\" not found in registry."
|
||||
},
|
||||
"missing_upload_permission": {
|
||||
"message": "Home Assistant was not granted permission to upload to Google Photos"
|
||||
},
|
||||
"no_access_to_path": {
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
},
|
||||
"upload_error": {
|
||||
"message": "Failed to upload content: {message}"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ from gspread.exceptions import APIError
|
||||
from gspread.utils import ValueInputOption
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@@ -21,8 +20,8 @@ from homeassistant.core import (
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
@@ -60,9 +59,9 @@ get_SHEET_SERVICE_SCHEMA = vol.All(
|
||||
|
||||
def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
|
||||
"""Run append in the executor."""
|
||||
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
|
||||
client = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
|
||||
try:
|
||||
sheet = service.open_by_key(entry.unique_id)
|
||||
sheet = client.open_by_key(entry.unique_id)
|
||||
except RefreshError:
|
||||
entry.async_start_reauth(call.hass)
|
||||
raise
|
||||
@@ -90,9 +89,9 @@ def _get_from_sheet(
|
||||
call: ServiceCall, entry: GoogleSheetsConfigEntry
|
||||
) -> JsonObjectType:
|
||||
"""Run get in the executor."""
|
||||
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
|
||||
client = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
|
||||
try:
|
||||
sheet = service.open_by_key(entry.unique_id)
|
||||
sheet = client.open_by_key(entry.unique_id)
|
||||
except RefreshError:
|
||||
entry.async_start_reauth(call.hass)
|
||||
raise
|
||||
@@ -106,27 +105,18 @@ def _get_from_sheet(
|
||||
|
||||
async def _async_append_to_sheet(call: ServiceCall) -> None:
|
||||
"""Append new line of data to a Google Sheets document."""
|
||||
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
|
||||
call.data[DATA_CONFIG_ENTRY]
|
||||
entry: GoogleSheetsConfigEntry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, call.data[DATA_CONFIG_ENTRY]
|
||||
)
|
||||
if not entry or not hasattr(entry, "runtime_data"):
|
||||
raise ValueError(f"Invalid config entry: {call.data[DATA_CONFIG_ENTRY]}")
|
||||
await entry.runtime_data.async_ensure_token_valid()
|
||||
await call.hass.async_add_executor_job(_append_to_sheet, call, entry)
|
||||
|
||||
|
||||
async def _async_get_from_sheet(call: ServiceCall) -> ServiceResponse:
|
||||
"""Get lines of data from a Google Sheets document."""
|
||||
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
|
||||
call.data[DATA_CONFIG_ENTRY]
|
||||
entry: GoogleSheetsConfigEntry = service.async_get_config_entry(
|
||||
call.hass, DOMAIN, call.data[DATA_CONFIG_ENTRY]
|
||||
)
|
||||
if entry is None:
|
||||
raise ServiceValidationError(
|
||||
f"Invalid config entry id: {call.data[DATA_CONFIG_ENTRY]}"
|
||||
)
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise HomeAssistantError(f"Config entry {entry.entry_id} is not loaded")
|
||||
|
||||
await entry.runtime_data.async_ensure_token_valid()
|
||||
return await call.hass.async_add_executor_job(_get_from_sheet, call, entry)
|
||||
|
||||
|
||||
@@ -43,7 +43,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
translation_key="highest_price_today",
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
value_fn=lambda api, data: api.get_highest_price_today(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_highest_price_today(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_highest_price_time",
|
||||
@@ -61,7 +65,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
translation_placeholders={"time_range": "(06:00-18:00)"},
|
||||
value_fn=lambda api, data: api.get_lowest_price_day(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_lowest_price_day(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_lowest_price_day_time",
|
||||
@@ -80,7 +88,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
translation_placeholders={"time_range": "(18:00-06:00)"},
|
||||
value_fn=lambda api, data: api.get_lowest_price_night(data),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_lowest_price_night(data)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
GreenPlanetEnergySensorEntityDescription(
|
||||
key="gpe_lowest_price_night_time",
|
||||
@@ -98,7 +110,11 @@ SENSOR_DESCRIPTIONS: list[GreenPlanetEnergySensorEntityDescription] = [
|
||||
translation_key="current_price",
|
||||
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
|
||||
suggested_display_precision=4,
|
||||
value_fn=lambda api, data: api.get_current_price(data, dt_util.now().hour),
|
||||
value_fn=lambda api, data: (
|
||||
price / 100
|
||||
if (price := api.get_current_price(data, dt_util.now().hour)) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
72
homeassistant/components/hegel/__init__.py
Normal file
72
homeassistant/components/hegel/__init__.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""The Hegel integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from hegel_ip_client import HegelClient
|
||||
from hegel_ip_client.exceptions import HegelConnectionError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DEFAULT_PORT
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HegelConfigEntry = ConfigEntry[HegelClient]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HegelConfigEntry) -> bool:
|
||||
"""Set up the Hegel integration."""
|
||||
host = entry.data[CONF_HOST]
|
||||
|
||||
# Create and test client connection
|
||||
client = HegelClient(host, DEFAULT_PORT)
|
||||
|
||||
try:
|
||||
# Test connection before proceeding with setup
|
||||
await client.start()
|
||||
await client.ensure_connected(timeout=10.0)
|
||||
_LOGGER.debug("Successfully connected to Hegel at %s:%s", host, DEFAULT_PORT)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
_LOGGER.error(
|
||||
"Failed to connect to Hegel at %s:%s: %s", host, DEFAULT_PORT, err
|
||||
)
|
||||
await client.stop() # Clean up
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to Hegel amplifier at {host}:{DEFAULT_PORT}"
|
||||
) from err
|
||||
|
||||
# Store client in runtime_data
|
||||
entry.runtime_data = client
|
||||
|
||||
async def _async_close_client(event):
|
||||
await client.stop()
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_client)
|
||||
)
|
||||
|
||||
# Forward setup to supported platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HegelConfigEntry) -> bool:
|
||||
"""Unload a Hegel config entry and stop active client connection."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
client = entry.runtime_data
|
||||
_LOGGER.debug("Stopping Hegel client for %s", entry.title)
|
||||
try:
|
||||
await client.stop()
|
||||
except (HegelConnectionError, OSError) as err:
|
||||
_LOGGER.warning("Error while stopping Hegel client: %s", err)
|
||||
|
||||
return unload_ok
|
||||
154
homeassistant/components/hegel/config_flow.py
Normal file
154
homeassistant/components/hegel/config_flow.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Config flow for Hegel integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hegel_ip_client import HegelClient
|
||||
from hegel_ip_client.exceptions import HegelConnectionError
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
|
||||
|
||||
from .const import CONF_MODEL, DEFAULT_PORT, DOMAIN, MODEL_INPUTS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HegelConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Hegel amplifiers."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._host: str | None = None
|
||||
self._name: str | None = None
|
||||
self._model: str | None = None
|
||||
|
||||
async def _async_try_connect(self, host: str) -> bool:
|
||||
"""Try to connect to the Hegel amplifier using the library."""
|
||||
client = HegelClient(host, DEFAULT_PORT)
|
||||
try:
|
||||
await client.start()
|
||||
await client.ensure_connected(timeout=5.0)
|
||||
except HegelConnectionError, TimeoutError, OSError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
finally:
|
||||
await client.stop()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle manual setup by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
|
||||
# Prevent duplicate entries by host
|
||||
self._async_abort_entries_match({CONF_HOST: host})
|
||||
|
||||
if not await self._async_try_connect(host):
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"Hegel {user_input[CONF_MODEL]}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_MODEL): vol.In(list(MODEL_INPUTS.keys())),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: SsdpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle SSDP discovery."""
|
||||
upnp = discovery_info.upnp or {}
|
||||
|
||||
# Get host from presentationURL or ssdp_location
|
||||
url = upnp.get("presentationURL") or discovery_info.ssdp_location
|
||||
if not url:
|
||||
return self.async_abort(reason="no_host_found")
|
||||
|
||||
host = URL(url).host
|
||||
if not host:
|
||||
return self.async_abort(reason="no_host_found")
|
||||
|
||||
# Use UDN as unique id (device UUID)
|
||||
unique_id = discovery_info.ssdp_udn
|
||||
if not unique_id:
|
||||
return self.async_abort(reason="no_host_found")
|
||||
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
# Test connection before showing confirmation
|
||||
if not await self._async_try_connect(host):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
# Get device info
|
||||
friendly_name = upnp.get("friendlyName", f"Hegel {host}")
|
||||
suggested_model = upnp.get("modelName") or ""
|
||||
model_default = next(
|
||||
(m for m in MODEL_INPUTS if suggested_model.upper().startswith(m.upper())),
|
||||
None,
|
||||
)
|
||||
|
||||
self._host = host
|
||||
self._name = friendly_name
|
||||
self._model = model_default
|
||||
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {"name": friendly_name},
|
||||
}
|
||||
)
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle discovery confirmation - user can change model if needed."""
|
||||
assert self._host is not None
|
||||
assert self._name is not None
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self._name,
|
||||
data={
|
||||
CONF_HOST: self._host,
|
||||
CONF_MODEL: user_input[CONF_MODEL],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_MODEL,
|
||||
default=self._model or list(MODEL_INPUTS.keys())[0],
|
||||
): vol.In(list(MODEL_INPUTS.keys())),
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"host": self._host,
|
||||
"name": self._name,
|
||||
},
|
||||
)
|
||||
92
homeassistant/components/hegel/const.py
Normal file
92
homeassistant/components/hegel/const.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Constants for the Hegel integration."""
|
||||
|
||||
DOMAIN = "hegel"
|
||||
DEFAULT_PORT = 50001
|
||||
|
||||
CONF_MODEL = "model"
|
||||
CONF_MAX_VOLUME = "max_volume" # 1.0 means amp's internal max
|
||||
|
||||
HEARTBEAT_TIMEOUT_MINUTES = 3
|
||||
|
||||
MODEL_INPUTS = {
|
||||
"Röst": [
|
||||
"Balanced",
|
||||
"Analog 1",
|
||||
"Analog 2",
|
||||
"Coaxial",
|
||||
"Optical 1",
|
||||
"Optical 2",
|
||||
"Optical 3",
|
||||
"USB",
|
||||
"Network",
|
||||
],
|
||||
"H95": [
|
||||
"Analog 1",
|
||||
"Analog 2",
|
||||
"Coaxial",
|
||||
"Optical 1",
|
||||
"Optical 2",
|
||||
"Optical 3",
|
||||
"USB",
|
||||
"Network",
|
||||
],
|
||||
"H120": [
|
||||
"Balanced",
|
||||
"Analog 1",
|
||||
"Analog 2",
|
||||
"Coaxial",
|
||||
"Optical 1",
|
||||
"Optical 2",
|
||||
"Optical 3",
|
||||
"USB",
|
||||
"Network",
|
||||
],
|
||||
"H190": [
|
||||
"Balanced",
|
||||
"Analog 1",
|
||||
"Analog 2",
|
||||
"Coaxial",
|
||||
"Optical 1",
|
||||
"Optical 2",
|
||||
"Optical 3",
|
||||
"USB",
|
||||
"Network",
|
||||
],
|
||||
"H190V": [
|
||||
"XLR",
|
||||
"Analog 1",
|
||||
"Analog 2",
|
||||
"Coaxial",
|
||||
"Optical 1",
|
||||
"Optical 2",
|
||||
"Optical 3",
|
||||
"USB",
|
||||
"Network",
|
||||
"Phono",
|
||||
],
|
||||
"H390": [
|
||||
"XLR",
|
||||
"Analog 1",
|
||||
"Analog 2",
|
||||
"BNC",
|
||||
"Coaxial",
|
||||
"Optical 1",
|
||||
"Optical 2",
|
||||
"Optical 3",
|
||||
"USB",
|
||||
"Network",
|
||||
],
|
||||
"H590": [
|
||||
"XLR 1",
|
||||
"XLR 2",
|
||||
"Analog 1",
|
||||
"Analog 2",
|
||||
"BNC",
|
||||
"Coaxial",
|
||||
"Optical 1",
|
||||
"Optical 2",
|
||||
"Optical 3",
|
||||
"USB",
|
||||
"Network",
|
||||
],
|
||||
}
|
||||
18
homeassistant/components/hegel/manifest.json
Normal file
18
homeassistant/components/hegel/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"domain": "hegel",
|
||||
"name": "Hegel Amplifier",
|
||||
"codeowners": ["@boazca"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hegel/",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["hegel_ip_client"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["hegel-ip-client==0.1.4"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
"manufacturer": "Hegel"
|
||||
}
|
||||
]
|
||||
}
|
||||
343
homeassistant/components/hegel/media_player.py
Normal file
343
homeassistant/components/hegel/media_player.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""Hegel media player platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from hegel_ip_client import (
|
||||
COMMANDS,
|
||||
HegelClient,
|
||||
apply_state_changes,
|
||||
parse_reply_message,
|
||||
)
|
||||
from hegel_ip_client.exceptions import HegelConnectionError
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import HegelConfigEntry
|
||||
from .const import CONF_MODEL, DOMAIN, HEARTBEAT_TIMEOUT_MINUTES, MODEL_INPUTS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HegelConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Hegel media player from a config entry."""
|
||||
model = entry.data[CONF_MODEL]
|
||||
unique_id = entry.unique_id or entry.entry_id
|
||||
|
||||
# map inputs (source_map)
|
||||
source_map: dict[int, str] = (
|
||||
dict(enumerate(MODEL_INPUTS[model], start=1)) if model in MODEL_INPUTS else {}
|
||||
)
|
||||
|
||||
# Use the client from the config entry's runtime_data (already connected)
|
||||
client = entry.runtime_data
|
||||
|
||||
# Create entity
|
||||
media = HegelMediaPlayer(
|
||||
entry,
|
||||
client,
|
||||
source_map,
|
||||
unique_id,
|
||||
)
|
||||
|
||||
async_add_entities([media])
|
||||
|
||||
|
||||
class HegelMediaPlayer(MediaPlayerEntity):
|
||||
"""Hegel amplifier entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_name = None
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: HegelConfigEntry,
|
||||
client: HegelClient,
|
||||
source_map: dict[int, str],
|
||||
unique_id: str,
|
||||
) -> None:
|
||||
"""Initialize the Hegel media player entity."""
|
||||
self._entry = config_entry
|
||||
self._client = client
|
||||
self._source_map = source_map
|
||||
|
||||
# Set unique_id from config entry
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
# Set device info
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=config_entry.title,
|
||||
manufacturer="Hegel",
|
||||
model=config_entry.data[CONF_MODEL],
|
||||
)
|
||||
|
||||
# State will be populated by async_update on first connection
|
||||
self._state: dict[str, Any] = {}
|
||||
|
||||
# Background tasks
|
||||
self._connected_watcher_task: asyncio.Task[None] | None = None
|
||||
self._push_task: asyncio.Task[None] | None = None
|
||||
self._push_handler: Callable[[str], None] | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
_LOGGER.debug("Hegel media player added to hass: %s", self.entity_id)
|
||||
|
||||
# Register push handler for real-time updates from the amplifier
|
||||
# The client expects a synchronous callable; schedule a coroutine safely
|
||||
def push_handler(msg: str) -> None:
|
||||
self._push_task = self.hass.async_create_task(self._async_handle_push(msg))
|
||||
|
||||
self._push_handler = push_handler
|
||||
self._client.add_push_callback(push_handler)
|
||||
|
||||
# Register cleanup for push handler using async_on_remove
|
||||
def cleanup_push_handler() -> None:
|
||||
if self._push_handler:
|
||||
self._client.remove_push_callback(self._push_handler)
|
||||
_LOGGER.debug("Push callback removed")
|
||||
self._push_handler = None
|
||||
|
||||
self.async_on_remove(cleanup_push_handler)
|
||||
|
||||
# Perform initial state fetch if already connected
|
||||
# The watcher handles reconnections, but we need to fetch state on first setup
|
||||
if self._client.is_connected():
|
||||
_LOGGER.debug("Client already connected, performing initial state fetch")
|
||||
await self.async_update()
|
||||
|
||||
# Start a watcher task
|
||||
# Use config_entry.async_create_background_task for automatic cleanup on unload
|
||||
self._connected_watcher_task = self._entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._connected_watcher(),
|
||||
name=f"hegel_{self.entity_id}_connected_watcher",
|
||||
)
|
||||
# Note: No need for async_on_remove - entry.async_create_background_task
|
||||
# automatically cancels the task when the config entry is unloaded
|
||||
|
||||
# Schedule the heartbeat every 2 minutes while the reset timeout is 3 minutes
|
||||
self.async_on_remove(
|
||||
async_track_time_interval(
|
||||
self.hass,
|
||||
self._send_heartbeat,
|
||||
timedelta(minutes=HEARTBEAT_TIMEOUT_MINUTES - 1),
|
||||
)
|
||||
)
|
||||
# Send the first heartbeat immediately
|
||||
self.hass.async_create_task(self._send_heartbeat())
|
||||
|
||||
async def _send_heartbeat(self, now=None) -> None:
|
||||
if not self.available:
|
||||
return
|
||||
try:
|
||||
await self._client.send(
|
||||
f"-r.{HEARTBEAT_TIMEOUT_MINUTES}", expect_reply=False
|
||||
)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
_LOGGER.debug("Heartbeat failed: %s", err)
|
||||
|
||||
async def _async_handle_push(self, msg: str) -> None:
|
||||
"""Handle incoming push message from client (runs in event loop)."""
|
||||
try:
|
||||
update = parse_reply_message(msg)
|
||||
if update.has_changes():
|
||||
apply_state_changes(self._state, update, logger=_LOGGER, source="push")
|
||||
# notify HA
|
||||
self.async_write_ha_state()
|
||||
except ValueError, KeyError, AttributeError:
|
||||
_LOGGER.exception("Failed to handle push message")
|
||||
|
||||
async def _connected_watcher(self) -> None:
|
||||
"""Watch the client's connection events and update state accordingly."""
|
||||
conn_event = self._client.connected_event
|
||||
disconn_event = self._client.disconnected_event
|
||||
_LOGGER.debug("Connected watcher started")
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for connection
|
||||
_LOGGER.debug("Watcher: waiting for connection")
|
||||
await conn_event.wait()
|
||||
_LOGGER.debug("Watcher: connected, refreshing state")
|
||||
|
||||
# Immediately notify HA that we're available again
|
||||
self.async_write_ha_state()
|
||||
|
||||
# Schedule a state refresh through HA
|
||||
self.async_schedule_update_ha_state(force_refresh=True)
|
||||
|
||||
# Wait for disconnection using event (no polling!)
|
||||
_LOGGER.debug("Watcher: waiting for disconnection")
|
||||
await disconn_event.wait()
|
||||
_LOGGER.debug("Watcher: disconnected")
|
||||
|
||||
# Notify HA that we're unavailable
|
||||
self.async_write_ha_state()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("Connected watcher cancelled")
|
||||
except (HegelConnectionError, OSError) as err:
|
||||
_LOGGER.warning("Connected watcher failed: %s", err)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Handle entity removal from Home Assistant.
|
||||
|
||||
Note: Push callback cleanup is handled by async_on_remove.
|
||||
_connected_watcher_task cleanup is handled automatically by
|
||||
entry.async_create_background_task when the config entry is unloaded.
|
||||
"""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
# Cancel push task if running (short-lived task, defensive cleanup)
|
||||
if self._push_task and not self._push_task.done():
|
||||
self._push_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._push_task
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Query the amplifier for the main values and update state dict."""
|
||||
for cmd in (
|
||||
COMMANDS["power_query"],
|
||||
COMMANDS["volume_query"],
|
||||
COMMANDS["mute_query"],
|
||||
COMMANDS["input_query"],
|
||||
):
|
||||
try:
|
||||
update = await self._client.send(cmd, expect_reply=True, timeout=3.0)
|
||||
if update and update.has_changes():
|
||||
apply_state_changes(
|
||||
self._state, update, logger=_LOGGER, source="update"
|
||||
)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
_LOGGER.debug("Refresh command %s failed: %s", cmd, err)
|
||||
# update entity state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the client is connected."""
|
||||
return self._client.is_connected()
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState | None:
|
||||
"""Return the current state of the media player."""
|
||||
power = self._state.get("power")
|
||||
if power is None:
|
||||
return None
|
||||
return MediaPlayerState.ON if power else MediaPlayerState.OFF
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the volume level."""
|
||||
volume = self._state.get("volume")
|
||||
if volume is None:
|
||||
return None
|
||||
return float(volume)
|
||||
|
||||
@property
|
||||
def is_volume_muted(self) -> bool | None:
|
||||
"""Return whether volume is muted."""
|
||||
return bool(self._state.get("mute", False))
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""Return the current input source."""
|
||||
idx = self._state.get("input")
|
||||
return self._source_map.get(idx, f"Input {idx}") if idx else None
|
||||
|
||||
@property
|
||||
def source_list(self) -> list[str] | None:
|
||||
"""Return the list of available input sources."""
|
||||
return [self._source_map[k] for k in sorted(self._source_map.keys())] or None
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the media player."""
|
||||
try:
|
||||
await self._client.send(COMMANDS["power_on"], expect_reply=False)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(f"Failed to turn on: {err}") from err
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the media player."""
|
||||
try:
|
||||
await self._client.send(COMMANDS["power_off"], expect_reply=False)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(f"Failed to turn off: {err}") from err
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
vol = max(0.0, min(volume, 1.0))
|
||||
amp_vol = int(round(vol * 100))
|
||||
try:
|
||||
await self._client.send(COMMANDS["volume_set"](amp_vol), expect_reply=False)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(f"Failed to set volume: {err}") from err
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or unmute the volume."""
|
||||
try:
|
||||
await self._client.send(
|
||||
COMMANDS["mute_on" if mute else "mute_off"], expect_reply=False
|
||||
)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(f"Failed to set mute: {err}") from err
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Increase volume."""
|
||||
try:
|
||||
await self._client.send(COMMANDS["volume_up"], expect_reply=False)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(f"Failed to increase volume: {err}") from err
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Decrease volume."""
|
||||
try:
|
||||
await self._client.send(COMMANDS["volume_down"], expect_reply=False)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(f"Failed to decrease volume: {err}") from err
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
inv = {v: k for k, v in self._source_map.items()}
|
||||
idx = inv.get(source)
|
||||
if idx is None:
|
||||
raise ServiceValidationError(f"Unknown source: {source}")
|
||||
try:
|
||||
await self._client.send(COMMANDS["input_set"](idx), expect_reply=False)
|
||||
except (HegelConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to select source {source}: {err}"
|
||||
) from err
|
||||
95
homeassistant/components/hegel/quality_scale.yaml
Normal file
95
homeassistant/components/hegel/quality_scale.yaml
Normal file
@@ -0,0 +1,95 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Entities subscribe to push events from hegel-ip-client library.
|
||||
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: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device uses local IP control without authentication.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
Single media_player entity, no categories needed.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
Single main entity, should be enabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No repair issues needed for this integration.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
Uses raw TCP connection, not HTTP.
|
||||
strict-typing: todo
|
||||
35
homeassistant/components/hegel/strings.json
Normal file
35
homeassistant/components/hegel/strings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_host_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"data": {
|
||||
"model": "Model"
|
||||
},
|
||||
"data_description": {
|
||||
"model": "Select your Hegel amplifier model for proper input mapping"
|
||||
},
|
||||
"description": "Discovered Hegel amplifier **{name}** at `{host}`. Confirm the model to complete setup.",
|
||||
"title": "Confirm Hegel amplifier"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"model": "Model"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP address of your Hegel amplifier",
|
||||
"model": "Select your Hegel amplifier model for proper input mapping"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,15 @@
|
||||
"platform_schema_validator_err": {
|
||||
"message": "Unknown error when validating config for {domain} from integration {p_name} - {error}."
|
||||
},
|
||||
"service_config_entry_not_found": {
|
||||
"message": "Integration {domain} config entry with ID {entry_id} was not found."
|
||||
},
|
||||
"service_config_entry_not_loaded": {
|
||||
"message": "Config entry {entry_title} for integration {domain} is not loaded."
|
||||
},
|
||||
"service_config_entry_wrong_domain": {
|
||||
"message": "Config entry {entry_title} does not belong to integration {domain}."
|
||||
},
|
||||
"service_does_not_support_response": {
|
||||
"message": "An action which does not return responses can't be called with {return_response}."
|
||||
},
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"sensor": {
|
||||
"valve_position": {
|
||||
"default": "mdi:pipe-valve"
|
||||
},
|
||||
"water_level": {
|
||||
"default": "mdi:water"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -356,6 +356,13 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
CharacteristicsTypes.WATER_LEVEL: HomeKitSensorEntityDescription(
|
||||
key=CharacteristicsTypes.WATER_LEVEL,
|
||||
name="Water level",
|
||||
translation_key="water_level",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: HomeKitSensorEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION,
|
||||
name="Valve position",
|
||||
|
||||
@@ -143,6 +143,9 @@
|
||||
"leader": "Leader",
|
||||
"router": "Router"
|
||||
}
|
||||
},
|
||||
"water_level": {
|
||||
"name": "Water level"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
from .helpers import smoke_detector_channel_data_exists
|
||||
|
||||
ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode"
|
||||
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
|
||||
@@ -125,6 +126,8 @@ async def async_setup_entry(
|
||||
entities.append(HomematicipPresenceDetector(hap, device))
|
||||
if isinstance(device, SmokeDetector):
|
||||
entities.append(HomematicipSmokeDetector(hap, device))
|
||||
if smoke_detector_channel_data_exists(device, "chamberDegraded"):
|
||||
entities.append(HomematicipSmokeDetectorChamberDegraded(hap, device))
|
||||
if isinstance(device, WaterSensor):
|
||||
entities.append(HomematicipWaterDetector(hap, device))
|
||||
if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)):
|
||||
@@ -322,6 +325,23 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
return False
|
||||
|
||||
|
||||
class HomematicipSmokeDetectorChamberDegraded(
|
||||
HomematicipGenericEntity, BinarySensorEntity
|
||||
):
|
||||
"""Representation of the HomematicIP smoke detector chamber health."""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize smoke detector chamber health sensor."""
|
||||
super().__init__(hap, device, post="Chamber Degraded")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if smoke chamber is degraded."""
|
||||
return self._device.chamberDegraded
|
||||
|
||||
|
||||
class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity):
|
||||
"""Representation of the HomematicIP water detector."""
|
||||
|
||||
|
||||
@@ -59,3 +59,16 @@ def get_channels_from_device(device: Device, channel_type: FunctionalChannelType
|
||||
for ch in device.functionalChannels
|
||||
if ch.functionalChannelType == channel_type
|
||||
]
|
||||
|
||||
|
||||
def smoke_detector_channel_data_exists(device: Device, field: str) -> bool:
|
||||
"""Check if a smoke detector's channel payload contains a specific field.
|
||||
|
||||
The library always initializes device attributes with defaults, so hasattr
|
||||
cannot distinguish between actual API data and defaults. This checks the
|
||||
raw channel payload to determine if the field was actually sent by the API.
|
||||
"""
|
||||
channels = get_channels_from_device(
|
||||
device, FunctionalChannelType.SMOKE_DETECTOR_CHANNEL
|
||||
)
|
||||
return bool(channels and field in getattr(channels[0], "_rawJSONData", {}))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"domain": "homematicip_cloud",
|
||||
"name": "HomematicIP Cloud",
|
||||
"codeowners": ["@hahn-th"],
|
||||
"codeowners": ["@hahn-th", "@lackas"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.5.0"]
|
||||
"requirements": ["homematicip==2.6.0"]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.enums import FunctionalChannelType, ValveState
|
||||
@@ -27,6 +29,7 @@ from homematicip.device import (
|
||||
PassageDetector,
|
||||
PresenceDetectorIndoor,
|
||||
RoomControlDeviceAnalog,
|
||||
SmokeDetector,
|
||||
SwitchMeasuring,
|
||||
TemperatureDifferenceSensor2,
|
||||
TemperatureHumiditySensorDisplay,
|
||||
@@ -43,6 +46,7 @@ from homematicip.device import (
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -65,7 +69,70 @@ from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
from .helpers import get_channels_from_device
|
||||
from .helpers import get_channels_from_device, smoke_detector_channel_data_exists
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HmipSmokeDetectorSensorDescription(SensorEntityDescription):
|
||||
"""Describes HmIP smoke detector sensor entity."""
|
||||
|
||||
value_fn: Callable[[SmokeDetector], StateType | datetime]
|
||||
channel_field: str # Field name in the raw channel payload
|
||||
|
||||
|
||||
SMOKE_DETECTOR_SENSORS: tuple[HmipSmokeDetectorSensorDescription, ...] = (
|
||||
HmipSmokeDetectorSensorDescription(
|
||||
key="dirt_level",
|
||||
translation_key="smoke_detector_dirt_level",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
channel_field="dirtLevel",
|
||||
value_fn=lambda d: (
|
||||
round(d.dirtLevel * 100, 1) if d.dirtLevel is not None else None
|
||||
),
|
||||
),
|
||||
HmipSmokeDetectorSensorDescription(
|
||||
key="smoke_alarm_counter",
|
||||
translation_key="smoke_detector_alarm_counter",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
channel_field="smokeAlarmCounter",
|
||||
value_fn=lambda d: d.smokeAlarmCounter,
|
||||
),
|
||||
HmipSmokeDetectorSensorDescription(
|
||||
key="smoke_test_counter",
|
||||
translation_key="smoke_detector_test_counter",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
channel_field="smokeTestCounter",
|
||||
value_fn=lambda d: d.smokeTestCounter,
|
||||
),
|
||||
HmipSmokeDetectorSensorDescription(
|
||||
key="last_smoke_alarm",
|
||||
translation_key="smoke_detector_last_alarm",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_registry_enabled_default=False,
|
||||
channel_field="lastSmokeAlarmTimestamp",
|
||||
value_fn=lambda d: (
|
||||
datetime.fromtimestamp(d.lastSmokeAlarmTimestamp / 1000, tz=UTC)
|
||||
if d.lastSmokeAlarmTimestamp
|
||||
else None
|
||||
),
|
||||
),
|
||||
HmipSmokeDetectorSensorDescription(
|
||||
key="last_smoke_test",
|
||||
translation_key="smoke_detector_last_test",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_registry_enabled_default=False,
|
||||
channel_field="lastSmokeTestTimestamp",
|
||||
value_fn=lambda d: (
|
||||
datetime.fromtimestamp(d.lastSmokeTestTimestamp / 1000, tz=UTC)
|
||||
if d.lastSmokeTestTimestamp
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
|
||||
ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle"
|
||||
@@ -289,6 +356,15 @@ async def async_setup_entry(
|
||||
and getattr(channel, "valvePosition", None) is not None
|
||||
)
|
||||
|
||||
# Handle smoke detector extended sensors (e.g., HmIP-SWSD-2)
|
||||
entities.extend(
|
||||
HmipSmokeDetectorSensor(hap, device, description)
|
||||
for device in hap.home.devices
|
||||
if isinstance(device, SmokeDetector)
|
||||
for description in SMOKE_DETECTOR_SENSORS
|
||||
if smoke_detector_channel_data_exists(device, description.channel_field)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -556,16 +632,8 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the state."""
|
||||
if self.functional_channel is None:
|
||||
return None
|
||||
|
||||
value = self.functional_channel.vaporAmount
|
||||
|
||||
# Handle case where value might be None
|
||||
if (
|
||||
self.functional_channel.vaporAmount is None
|
||||
or self.functional_channel.vaporAmount == ""
|
||||
):
|
||||
value = self._device.vaporAmount
|
||||
if value is None or value == "":
|
||||
return None
|
||||
|
||||
return round(value, 3)
|
||||
@@ -936,6 +1004,33 @@ class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEnt
|
||||
return state_attr
|
||||
|
||||
|
||||
class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Sensor for HomematicIP smoke detector extended properties."""
|
||||
|
||||
entity_description: HmipSmokeDetectorSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hap: HomematicipHAP,
|
||||
device: SmokeDetector,
|
||||
description: HmipSmokeDetectorSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the smoke detector sensor."""
|
||||
super().__init__(hap, device, post=description.key)
|
||||
self.entity_description = description
|
||||
self._sensor_unique_id = f"{device.id}_{description.key}"
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
return self._sensor_unique_id
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self._device)
|
||||
|
||||
|
||||
def _get_wind_direction(wind_direction_degree: float) -> str:
|
||||
"""Convert wind direction degree to named direction."""
|
||||
if 11.25 <= wind_direction_degree < 33.75:
|
||||
|
||||
@@ -29,6 +29,21 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"smoke_detector_alarm_counter": {
|
||||
"name": "Alarm counter"
|
||||
},
|
||||
"smoke_detector_dirt_level": {
|
||||
"name": "Dirt level"
|
||||
},
|
||||
"smoke_detector_last_alarm": {
|
||||
"name": "Last alarm"
|
||||
},
|
||||
"smoke_detector_last_test": {
|
||||
"name": "Last test"
|
||||
},
|
||||
"smoke_detector_test_counter": {
|
||||
"name": "Test counter"
|
||||
},
|
||||
"tilt_state": {
|
||||
"state": {
|
||||
"neutral": "Neutral",
|
||||
|
||||
@@ -17,6 +17,7 @@ from homematicip.device import (
|
||||
PlugableSwitch,
|
||||
PrintedCircuitBoardSwitch2,
|
||||
PrintedCircuitBoardSwitchBattery,
|
||||
StatusBoard8,
|
||||
SwitchMeasuring,
|
||||
WiredInput32,
|
||||
WiredInputSwitch6,
|
||||
@@ -57,6 +58,7 @@ async def async_setup_entry(
|
||||
WiredSwitch4,
|
||||
WiredSwitch8,
|
||||
OpenCollector8Module,
|
||||
StatusBoard8,
|
||||
BrandSwitch2,
|
||||
PrintedCircuitBoardSwitch2,
|
||||
HeatingSwitch2,
|
||||
|
||||
@@ -28,7 +28,7 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
MAX_WS_RECONNECT_TIME = 600
|
||||
SCAN_INTERVAL = timedelta(minutes=8)
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
|
||||
PING_INTERVAL = 60
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2.7.1"]
|
||||
"requirements": ["aioautomower==2.7.3"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioimmich==0.11.1"]
|
||||
"requirements": ["aioimmich==0.12.0"]
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ from aioimmich.exceptions import ImmichError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_source import async_resolve_media
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import service
|
||||
from homeassistant.helpers.selector import MediaSelector
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -38,23 +38,11 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
|
||||
service_call.data,
|
||||
)
|
||||
hass = service_call.hass
|
||||
target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
service_call.data[CONF_CONFIG_ENTRY_ID]
|
||||
target_entry: ImmichConfigEntry = service.async_get_config_entry(
|
||||
hass, DOMAIN, service_call.data[CONF_CONFIG_ENTRY_ID]
|
||||
)
|
||||
source_media_id = service_call.data[CONF_FILE]["media_content_id"]
|
||||
|
||||
if not target_entry:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
)
|
||||
|
||||
if target_entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
)
|
||||
|
||||
media = await async_resolve_media(hass, source_media_id, None)
|
||||
if media.path is None:
|
||||
raise ServiceValidationError(
|
||||
|
||||
@@ -79,12 +79,6 @@
|
||||
"album_not_found": {
|
||||
"message": "Album with ID `{album_id}` not found ({error})."
|
||||
},
|
||||
"config_entry_not_found": {
|
||||
"message": "Config entry not found."
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
},
|
||||
"only_local_media_supported": {
|
||||
"message": "Only local media files are currently supported."
|
||||
},
|
||||
|
||||
51
homeassistant/components/intelliclima/__init__.py
Normal file
51
homeassistant/components/intelliclima/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""The IntelliClima VMC integration."""
|
||||
|
||||
from pyintelliclima.api import IntelliClimaAPI
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import LOGGER
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
|
||||
PLATFORMS = [Platform.FAN]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: IntelliClimaConfigEntry
|
||||
) -> bool:
|
||||
"""Set up IntelliClima VMC from a config entry."""
|
||||
# Create API client
|
||||
session = async_get_clientsession(hass)
|
||||
api = IntelliClimaAPI(
|
||||
session,
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
# Create coordinator
|
||||
coordinator = IntelliClimaCoordinator(hass, entry, api)
|
||||
|
||||
# Fetch initial data
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
LOGGER.debug(
|
||||
"Discovered %d IntelliClima VMC device(s)",
|
||||
len(coordinator.data.ecocomfort2_devices),
|
||||
)
|
||||
|
||||
# Store coordinator
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
# Set up platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: IntelliClimaConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
71
homeassistant/components/intelliclima/config_flow.py
Normal file
71
homeassistant/components/intelliclima/config_flow.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Config flow for IntelliClima integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyintelliclima import IntelliClimaAPI, IntelliClimaAPIError, IntelliClimaAuthError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class IntelliClimaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for IntelliClima VMC."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
|
||||
|
||||
# Validate credentials
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = IntelliClimaAPI(
|
||||
session,
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
try:
|
||||
# Test authentication
|
||||
await api.authenticate()
|
||||
|
||||
# Get devices to ensure we can communicate with API
|
||||
devices = await api.get_all_device_status()
|
||||
|
||||
except IntelliClimaAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except IntelliClimaAPIError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if devices.num_devices == 0:
|
||||
errors["base"] = "no_devices"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"IntelliClima ({user_input[CONF_USERNAME]})",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
11
homeassistant/components/intelliclima/const.py
Normal file
11
homeassistant/components/intelliclima/const.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Constants for the IntelliClima integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DOMAIN = "intelliclima"
|
||||
|
||||
# Update interval
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)
|
||||
45
homeassistant/components/intelliclima/coordinator.py
Normal file
45
homeassistant/components/intelliclima/coordinator.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""DataUpdateCoordinator for IntelliClima."""
|
||||
|
||||
from pyintelliclima import IntelliClimaAPI, IntelliClimaAPIError, IntelliClimaDevices
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER
|
||||
|
||||
type IntelliClimaConfigEntry = ConfigEntry[IntelliClimaCoordinator]
|
||||
|
||||
|
||||
class IntelliClimaCoordinator(DataUpdateCoordinator[IntelliClimaDevices]):
|
||||
"""Coordinator to manage fetching IntelliClima data."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: IntelliClimaConfigEntry, api: IntelliClimaAPI
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator - called once during first refresh."""
|
||||
# Authenticate and get initial device list
|
||||
try:
|
||||
await self.api.authenticate()
|
||||
except IntelliClimaAPIError as err:
|
||||
raise UpdateFailed(f"Failed to set up IntelliClima: {err}") from err
|
||||
|
||||
async def _async_update_data(self) -> IntelliClimaDevices:
|
||||
"""Fetch data from API."""
|
||||
try:
|
||||
# Poll status for all devices
|
||||
return await self.api.get_all_device_status()
|
||||
|
||||
except IntelliClimaAPIError as err:
|
||||
raise UpdateFailed(f"Failed to update data: {err}") from err
|
||||
74
homeassistant/components/intelliclima/entity.py
Normal file
74
homeassistant/components/intelliclima/entity.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Platform for shared base classes for sensors."""
|
||||
|
||||
from pyintelliclima.intelliclima_types import IntelliClimaC800, IntelliClimaECO
|
||||
|
||||
from homeassistant.const import ATTR_CONNECTIONS, ATTR_MODEL, ATTR_SW_VERSION
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_BLUETOOTH,
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IntelliClimaCoordinator
|
||||
|
||||
|
||||
class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]):
|
||||
"""Define a generic class for IntelliClima entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IntelliClimaCoordinator,
|
||||
device: IntelliClimaECO | IntelliClimaC800,
|
||||
) -> None:
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
|
||||
self._attr_unique_id = device.id
|
||||
|
||||
# Make this HA "device" use the IntelliClima device name.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
manufacturer="Fantini Cosmi",
|
||||
name=device.name,
|
||||
serial_number=device.crono_sn,
|
||||
)
|
||||
|
||||
self._device_id = device.id
|
||||
self._device_sn = device.crono_sn
|
||||
|
||||
|
||||
class IntelliClimaECOEntity(IntelliClimaEntity):
|
||||
"""Specific entity for the ECOCOMFORT 2.0."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IntelliClimaCoordinator,
|
||||
device: IntelliClimaECO,
|
||||
) -> None:
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self._attr_device_info: DeviceInfo = self.device_info or DeviceInfo()
|
||||
|
||||
self._attr_device_info[ATTR_MODEL] = "ECOCOMFORT 2.0"
|
||||
self._attr_device_info[ATTR_SW_VERSION] = device.fw
|
||||
self._attr_device_info[ATTR_CONNECTIONS] = {
|
||||
(CONNECTION_BLUETOOTH, device.mac),
|
||||
(CONNECTION_NETWORK_MAC, device.macwifi),
|
||||
}
|
||||
|
||||
@property
|
||||
def _device_data(self) -> IntelliClimaECO:
|
||||
return self.coordinator.data.ecocomfort2_devices[self._device_id]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self._device_id in self.coordinator.data.ecocomfort2_devices
|
||||
)
|
||||
173
homeassistant/components/intelliclima/fan.py
Normal file
173
homeassistant/components/intelliclima/fan.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Fan platform for IntelliClima VMC."""
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from pyintelliclima.const import FanMode, FanSpeed
|
||||
from pyintelliclima.intelliclima_types import IntelliClimaECO
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
percentage_to_ranged_value,
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
from .entity import IntelliClimaECOEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IntelliClimaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up IntelliClima VMC fans."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[IntelliClimaVMCFan] = [
|
||||
IntelliClimaVMCFan(
|
||||
coordinator=coordinator,
|
||||
device=ecocomfort2,
|
||||
)
|
||||
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
"""Representation of an IntelliClima VMC fan."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_preset_modes = ["auto"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IntelliClimaCoordinator,
|
||||
device: IntelliClimaECO,
|
||||
) -> None:
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self._speed_range = (int(FanSpeed.sleep), int(FanSpeed.high))
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if fan is on."""
|
||||
return bool(self._device_data.mode_set != FanMode.off)
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed percentage."""
|
||||
device_data = self._device_data
|
||||
|
||||
if device_data.speed_set == FanSpeed.auto:
|
||||
return None
|
||||
|
||||
return ranged_value_to_percentage(self._speed_range, int(device_data.speed_set))
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
return int_states_in_range(self._speed_range)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
device_data = self._device_data
|
||||
|
||||
if device_data.mode_set == FanMode.off:
|
||||
return None
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
and device_data.mode_set == FanMode.sensor
|
||||
):
|
||||
return "auto"
|
||||
|
||||
return None
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan.
|
||||
|
||||
Defaults back to 25% if percentage argument is 0 to prevent loop of turning off/on
|
||||
infinitely.
|
||||
"""
|
||||
percentage = 25 if percentage == 0 else percentage
|
||||
await self.async_set_mode_speed(fan_mode=preset_mode, percentage=percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the fan."""
|
||||
await self.coordinator.api.ecocomfort.turn_off(self._device_sn)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage."""
|
||||
await self.async_set_mode_speed(percentage=percentage)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set preset mode."""
|
||||
await self.async_set_mode_speed(fan_mode=preset_mode)
|
||||
|
||||
async def async_set_mode_speed(
|
||||
self, fan_mode: str | None = None, percentage: int | None = None
|
||||
) -> None:
|
||||
"""Set mode and speed.
|
||||
|
||||
If percentage is None, it first defaults to the respective property.
|
||||
If that is also None, then percentage defaults to 25 (sleep)
|
||||
"""
|
||||
percentage = self.percentage if percentage is None else percentage
|
||||
percentage = 25 if percentage is None else percentage
|
||||
|
||||
if fan_mode == "auto":
|
||||
# auto is a special case with special mode and speed setting
|
||||
await self.coordinator.api.ecocomfort.set_mode_speed_auto(self._device_sn)
|
||||
await self.coordinator.async_request_refresh()
|
||||
return
|
||||
if percentage == 0:
|
||||
# Setting fan speed to zero turns off the fan
|
||||
await self.async_turn_off()
|
||||
return
|
||||
|
||||
# Determine the fan mode
|
||||
if fan_mode is not None:
|
||||
# Set to requested fan_mode
|
||||
mode = fan_mode
|
||||
elif not self.is_on:
|
||||
# Default to alternate fan mode if not turned on
|
||||
mode = FanMode.alternate
|
||||
else:
|
||||
# Maintain current mode
|
||||
mode = self._device_data.mode_set
|
||||
|
||||
speed = str(
|
||||
math.ceil(
|
||||
percentage_to_ranged_value(
|
||||
self._speed_range,
|
||||
percentage,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
speed = FanSpeed.sleep if speed == FanSpeed.off else speed
|
||||
await self.coordinator.api.ecocomfort.set_mode_speed(
|
||||
self._device_sn, mode, speed
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
11
homeassistant/components/intelliclima/manifest.json
Normal file
11
homeassistant/components/intelliclima/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "intelliclima",
|
||||
"name": "IntelliClima",
|
||||
"codeowners": ["@dvdinth"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/intelliclima",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyintelliclima==0.2.2"]
|
||||
}
|
||||
75
homeassistant/components/intelliclima/quality_scale.yaml
Normal file
75
homeassistant/components/intelliclima/quality_scale.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
No configuration parameters, so nothing to document.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Currently 92% average, with minimum module at 80% coverage.
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: todo
|
||||
comment: |
|
||||
Unclear if discovery is possible.
|
||||
discovery:
|
||||
status: todo
|
||||
comment: |
|
||||
Unclear if discovery is possible.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: |
|
||||
External pyintelliclima module does not fully conform to PEP 561 yet.
|
||||
26
homeassistant/components/intelliclima/strings.json
Normal file
26
homeassistant/components/intelliclima/strings.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"no_devices": "No IntelliClima devices found in your account",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Your IntelliClima app password",
|
||||
"username": "Your IntelliClima app username"
|
||||
},
|
||||
"description": "Authenticate against IntelliClima cloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["intellifire4py"],
|
||||
"requirements": ["intellifire4py==4.2.1"]
|
||||
"requirements": ["intellifire4py==4.3.1"]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
TIMER_NOT_FOUND_RESPONSE = "timer_not_found"
|
||||
MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
|
||||
NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
|
||||
NO_TIMER_COMMAND_RESPONSE = "no_timer_command"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -192,6 +193,17 @@ class MultipleTimersMatchedError(intent.IntentHandleError):
|
||||
super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE)
|
||||
|
||||
|
||||
class NoTimerCommandError(intent.IntentHandleError):
|
||||
"""Error when a conversation command does not match any intent."""
|
||||
|
||||
def __init__(self, command: str) -> None:
|
||||
"""Initialize error."""
|
||||
super().__init__(
|
||||
f"Intent not recognized: {command}",
|
||||
NO_TIMER_COMMAND_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
class TimersNotSupportedError(intent.IntentHandleError):
|
||||
"""Error when a timer intent is used from a device that isn't registered to handle timer events."""
|
||||
|
||||
@@ -836,6 +848,12 @@ class StartTimerIntentHandler(intent.IntentHandler):
|
||||
# Fail early if this is not a delayed command
|
||||
raise TimersNotSupportedError(intent_obj.device_id)
|
||||
|
||||
# Validate conversation command if provided
|
||||
if conversation_command and not await self._validate_conversation_command(
|
||||
intent_obj, conversation_command
|
||||
):
|
||||
raise NoTimerCommandError(conversation_command)
|
||||
|
||||
name: str | None = None
|
||||
if "name" in slots:
|
||||
name = slots["name"]["value"]
|
||||
@@ -865,6 +883,48 @@ class StartTimerIntentHandler(intent.IntentHandler):
|
||||
|
||||
return intent_obj.create_response()
|
||||
|
||||
async def _validate_conversation_command(
|
||||
self, intent_obj: intent.Intent, conversation_command: str
|
||||
) -> bool:
|
||||
"""Validate that a conversation command can be executed."""
|
||||
from homeassistant.components.conversation import ( # noqa: PLC0415
|
||||
ConversationInput,
|
||||
async_get_agent,
|
||||
default_agent,
|
||||
)
|
||||
|
||||
# Only validate if using the default agent
|
||||
conversation_agent = async_get_agent(
|
||||
intent_obj.hass, intent_obj.conversation_agent_id
|
||||
)
|
||||
|
||||
if conversation_agent is None or not isinstance(
|
||||
conversation_agent, default_agent.DefaultAgent
|
||||
):
|
||||
return True # Skip validation
|
||||
|
||||
test_input = ConversationInput(
|
||||
text=conversation_command,
|
||||
context=intent_obj.context,
|
||||
conversation_id=None,
|
||||
device_id=intent_obj.device_id,
|
||||
satellite_id=intent_obj.satellite_id,
|
||||
language=intent_obj.language,
|
||||
agent_id=conversation_agent.entity_id,
|
||||
)
|
||||
|
||||
# check for sentence trigger
|
||||
if (
|
||||
await conversation_agent.async_recognize_sentence_trigger(test_input)
|
||||
) is not None:
|
||||
return True
|
||||
|
||||
# check for intent
|
||||
if (await conversation_agent.async_recognize_intent(test_input)) is not None:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CancelTimerIntentHandler(intent.IntentHandler):
|
||||
"""Intent handler for cancelling a timer."""
|
||||
|
||||
@@ -30,7 +30,7 @@ from .entity import IOmeterEntity
|
||||
class IOmeterEntityDescription(SensorEntityDescription):
|
||||
"""Describes IOmeter sensor entity."""
|
||||
|
||||
value_fn: Callable[[IOmeterData], str | int | float]
|
||||
value_fn: Callable[[IOmeterData], str | int | float | None]
|
||||
|
||||
|
||||
SENSOR_TYPES: list[IOmeterEntityDescription] = [
|
||||
@@ -73,7 +73,11 @@ SENSOR_TYPES: list[IOmeterEntityDescription] = [
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: int(round(data.status.device.core.battery_level)),
|
||||
value_fn=lambda data: (
|
||||
int(round(data.status.device.core.battery_level))
|
||||
if data.status.device.core.battery_level is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
IOmeterEntityDescription(
|
||||
key="pin_status",
|
||||
|
||||
@@ -21,6 +21,7 @@ from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
|
||||
from .helpers import (
|
||||
async_is_preview_feature_enabled,
|
||||
async_listen,
|
||||
async_subscribe_preview_feature,
|
||||
async_update_preview_feature,
|
||||
)
|
||||
from .models import (
|
||||
@@ -41,6 +42,7 @@ __all__ = [
|
||||
"EventLabsUpdatedData",
|
||||
"async_is_preview_feature_enabled",
|
||||
"async_listen",
|
||||
"async_subscribe_preview_feature",
|
||||
"async_update_preview_feature",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import EVENT_LABS_UPDATED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
@@ -32,6 +33,43 @@ def async_is_preview_feature_enabled(
|
||||
return (domain, preview_feature) in labs_data.data.preview_feature_status
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_preview_feature(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
preview_feature: str,
|
||||
listener: Callable[[EventLabsUpdatedData], Coroutine[Any, Any, None]],
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for changes to a specific preview feature.
|
||||
|
||||
Args:
|
||||
hass: HomeAssistant instance
|
||||
domain: Integration domain
|
||||
preview_feature: Preview feature name
|
||||
listener: Coroutine function to invoke when the preview feature
|
||||
is toggled. Receives the event data as argument. Runs eagerly.
|
||||
|
||||
Returns:
|
||||
Callable to unsubscribe from the listener
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _async_event_filter(event_data: EventLabsUpdatedData) -> bool:
|
||||
"""Filter labs events for this integration's preview feature."""
|
||||
return (
|
||||
event_data["domain"] == domain
|
||||
and event_data["preview_feature"] == preview_feature
|
||||
)
|
||||
|
||||
async def _handler(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
await listener(event.data)
|
||||
|
||||
return hass.bus.async_listen(
|
||||
EVENT_LABS_UPDATED, _handler, event_filter=_async_event_filter
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_listen(
|
||||
hass: HomeAssistant,
|
||||
@@ -51,16 +89,10 @@ def async_listen(
|
||||
Callable to unsubscribe from the listener
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _async_feature_updated(event: Event[EventLabsUpdatedData]) -> None:
|
||||
"""Handle labs feature update event."""
|
||||
if (
|
||||
event.data["domain"] == domain
|
||||
and event.data["preview_feature"] == preview_feature
|
||||
):
|
||||
listener()
|
||||
async def _listener(_event_data: EventLabsUpdatedData) -> None:
|
||||
listener()
|
||||
|
||||
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
|
||||
return async_subscribe_preview_feature(hass, domain, preview_feature, _listener)
|
||||
|
||||
|
||||
async def async_update_preview_feature(
|
||||
|
||||
@@ -13,9 +13,10 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from .const import LABS_DATA
|
||||
from .helpers import (
|
||||
async_is_preview_feature_enabled,
|
||||
async_listen,
|
||||
async_subscribe_preview_feature,
|
||||
async_update_preview_feature,
|
||||
)
|
||||
from .models import EventLabsUpdatedData
|
||||
|
||||
|
||||
@callback
|
||||
@@ -102,7 +103,6 @@ async def websocket_update_preview_feature(
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "labs/subscribe",
|
||||
@@ -110,7 +110,8 @@ async def websocket_update_preview_feature(
|
||||
vol.Required("preview_feature"): str,
|
||||
}
|
||||
)
|
||||
def websocket_subscribe_feature(
|
||||
@websocket_api.async_response
|
||||
async def websocket_subscribe_feature(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
@@ -132,10 +133,13 @@ def websocket_subscribe_feature(
|
||||
|
||||
preview_feature = labs_data.preview_features[preview_feature_id]
|
||||
|
||||
@callback
|
||||
def send_event() -> None:
|
||||
async def send_event(event_data: EventLabsUpdatedData | None = None) -> None:
|
||||
"""Send feature state to client."""
|
||||
enabled = async_is_preview_feature_enabled(hass, domain, preview_feature_key)
|
||||
enabled = (
|
||||
event_data["enabled"]
|
||||
if event_data is not None
|
||||
else async_is_preview_feature_enabled(hass, domain, preview_feature_key)
|
||||
)
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg["id"],
|
||||
@@ -143,9 +147,9 @@ def websocket_subscribe_feature(
|
||||
)
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_listen(
|
||||
connection.subscriptions[msg["id"]] = async_subscribe_preview_feature(
|
||||
hass, domain, preview_feature_key, send_event
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
send_event()
|
||||
await send_event()
|
||||
|
||||
@@ -57,6 +57,14 @@ ROBOT_STATUS_TO_HA = {
|
||||
"sleep": VacuumActivity.IDLE,
|
||||
"standby": VacuumActivity.IDLE,
|
||||
"working": VacuumActivity.CLEANING,
|
||||
"station": VacuumActivity.CLEANING,
|
||||
"station_dry": VacuumActivity.CLEANING,
|
||||
"clean_learning": VacuumActivity.CLEANING,
|
||||
"station_mop": VacuumActivity.CLEANING,
|
||||
"water_removal": VacuumActivity.CLEANING,
|
||||
"water_injection": VacuumActivity.CLEANING,
|
||||
"clean_select": VacuumActivity.CLEANING,
|
||||
"clean_select_gozone": VacuumActivity.CLEANING,
|
||||
"error": VacuumActivity.ERROR,
|
||||
}
|
||||
ROBOT_BATT_TO_HA = {
|
||||
@@ -111,7 +119,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity):
|
||||
super()._update_status()
|
||||
|
||||
# Update state.
|
||||
self._attr_activity = ROBOT_STATUS_TO_HA[self.data.current_state]
|
||||
self._attr_activity = ROBOT_STATUS_TO_HA.get(self.data.current_state)
|
||||
|
||||
# Update battery.
|
||||
if (level := self.data.battery) is not None:
|
||||
|
||||
34
homeassistant/components/liebherr/diagnostics.py
Normal file
34
homeassistant/components/liebherr/diagnostics.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Diagnostics support for Liebherr."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import LiebherrConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_API_KEY}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: LiebherrConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"devices": {
|
||||
device_id: {
|
||||
"coordinator": {
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"update_interval": str(coordinator.update_interval),
|
||||
"last_exception": str(coordinator.last_exception)
|
||||
if coordinator.last_exception
|
||||
else None,
|
||||
},
|
||||
"data": asdict(coordinator.data),
|
||||
}
|
||||
for device_id, coordinator in entry.runtime_data.items()
|
||||
},
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Cloud API does not require updating entry data from network discovery.
|
||||
|
||||
@@ -805,39 +805,6 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the color mode of the light."""
|
||||
return self._attr_color_mode
|
||||
|
||||
@property
|
||||
def _light_internal_color_mode(self) -> str:
|
||||
"""Return the color mode of the light with backwards compatibility."""
|
||||
if (color_mode := self.color_mode) is None:
|
||||
# Backwards compatibility for color_mode added in 2021.4
|
||||
# Warning added in 2024.3, break in 2025.3
|
||||
if not self.__color_mode_reported and self.__should_report_light_issue():
|
||||
self.__color_mode_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s (%s) does not report a color mode, this will stop working "
|
||||
"in Home Assistant Core 2025.3, please %s"
|
||||
),
|
||||
self.entity_id,
|
||||
type(self),
|
||||
report_issue,
|
||||
)
|
||||
|
||||
supported = self._light_internal_supported_color_modes
|
||||
|
||||
if ColorMode.HS in supported and self.hs_color is not None:
|
||||
return ColorMode.HS
|
||||
if ColorMode.COLOR_TEMP in supported and self.color_temp_kelvin is not None:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if ColorMode.BRIGHTNESS in supported and self.brightness is not None:
|
||||
return ColorMode.BRIGHTNESS
|
||||
if ColorMode.ONOFF in supported:
|
||||
return ColorMode.ONOFF
|
||||
return ColorMode.UNKNOWN
|
||||
|
||||
return color_mode
|
||||
|
||||
@cached_property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
"""Return the hue and saturation color value [float, float]."""
|
||||
@@ -985,8 +952,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
def __validate_color_mode(
|
||||
self,
|
||||
color_mode: ColorMode | str | None,
|
||||
supported_color_modes: set[ColorMode] | set[str],
|
||||
color_mode: ColorMode | None,
|
||||
supported_color_modes: set[ColorMode],
|
||||
effect: str | None,
|
||||
) -> None:
|
||||
"""Validate the color mode."""
|
||||
@@ -999,23 +966,10 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
# color modes
|
||||
if color_mode in supported_color_modes:
|
||||
return
|
||||
# Warning added in 2024.3, reject in 2025.3
|
||||
if not self.__color_mode_reported and self.__should_report_light_issue():
|
||||
self.__color_mode_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s (%s) set to unsupported color mode %s, expected one of %s, "
|
||||
"this will stop working in Home Assistant Core 2025.3, "
|
||||
"please %s"
|
||||
),
|
||||
self.entity_id,
|
||||
type(self),
|
||||
color_mode,
|
||||
supported_color_modes,
|
||||
report_issue,
|
||||
)
|
||||
return
|
||||
raise HomeAssistantError(
|
||||
f"{self.entity_id} ({type(self)}) set to unsupported color mode "
|
||||
f"{color_mode}, expected one of {supported_color_modes}"
|
||||
)
|
||||
|
||||
# When an effect is active, the color mode should indicate what adjustments are
|
||||
# supported by the effect. To make this possible, we allow the light to set its
|
||||
@@ -1028,49 +982,24 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
if color_mode in effect_color_modes:
|
||||
return
|
||||
|
||||
# Warning added in 2024.3, reject in 2025.3
|
||||
if not self.__color_mode_reported and self.__should_report_light_issue():
|
||||
self.__color_mode_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s (%s) set to unsupported color mode %s when rendering an effect,"
|
||||
" expected one of %s, this will stop working in Home Assistant "
|
||||
"Core 2025.3, please %s"
|
||||
),
|
||||
self.entity_id,
|
||||
type(self),
|
||||
color_mode,
|
||||
effect_color_modes,
|
||||
report_issue,
|
||||
)
|
||||
return
|
||||
raise HomeAssistantError(
|
||||
f"{self.entity_id} ({type(self)}) set to unsupported color mode "
|
||||
f"{color_mode} when rendering an effect, expected one "
|
||||
f"of {effect_color_modes}"
|
||||
)
|
||||
|
||||
def __validate_supported_color_modes(
|
||||
self,
|
||||
supported_color_modes: set[ColorMode],
|
||||
) -> None:
|
||||
"""Validate the supported color modes."""
|
||||
if self.__color_mode_reported:
|
||||
return
|
||||
|
||||
try:
|
||||
valid_supported_color_modes(supported_color_modes)
|
||||
except vol.Error:
|
||||
# Warning added in 2024.3, reject in 2025.3
|
||||
if not self.__color_mode_reported and self.__should_report_light_issue():
|
||||
self.__color_mode_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s (%s) sets invalid supported color modes %s, this will stop "
|
||||
"working in Home Assistant Core 2025.3, please %s"
|
||||
),
|
||||
self.entity_id,
|
||||
type(self),
|
||||
supported_color_modes,
|
||||
report_issue,
|
||||
)
|
||||
except vol.Error as err:
|
||||
raise HomeAssistantError(
|
||||
f"{self.entity_id} ({type(self)}) sets invalid supported color modes "
|
||||
f"{supported_color_modes}"
|
||||
) from err
|
||||
|
||||
@final
|
||||
@property
|
||||
@@ -1084,13 +1013,17 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
)
|
||||
|
||||
_is_on = self.is_on
|
||||
color_mode = self._light_internal_color_mode if _is_on else None
|
||||
color_mode = self.color_mode if _is_on else None
|
||||
if _is_on and color_mode is None:
|
||||
raise HomeAssistantError(
|
||||
f"{self.entity_id} ({type(self)}) does not report a color mode"
|
||||
)
|
||||
|
||||
effect: str | None
|
||||
effect: str | None = None
|
||||
if LightEntityFeature.EFFECT in supported_features:
|
||||
data[ATTR_EFFECT] = effect = self.effect if _is_on else None
|
||||
else:
|
||||
effect = None
|
||||
if _is_on:
|
||||
effect = self.effect
|
||||
data[ATTR_EFFECT] = effect
|
||||
|
||||
self.__validate_color_mode(color_mode, legacy_supported_color_modes, effect)
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
# Dashboard Creation Guide
|
||||
|
||||
This guide provides best practices for building effective Home Assistant dashboards.
|
||||
|
||||
## Basic Structure of a Dashboard
|
||||
|
||||
A dashboard is a collection of views, and each view contains sections with cards. The basic structure looks like this:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
- title: Living Room
|
||||
path: living-room
|
||||
icon: mdi:sofa
|
||||
badges:
|
||||
- type: entity
|
||||
entity: sensor.living_room_temperature
|
||||
- type: entity
|
||||
entity: sensor.living_room_humidity
|
||||
sections:
|
||||
- type: grid
|
||||
title: Lights
|
||||
cards:
|
||||
- type: tile
|
||||
entity: light.living_room_ceiling
|
||||
features:
|
||||
- type: light-brightness
|
||||
- type: tile
|
||||
entity: light.floor_lamp
|
||||
- type: tile
|
||||
entity: light.reading_lamp
|
||||
- type: grid
|
||||
title: Climate
|
||||
cards:
|
||||
- type: thermostat
|
||||
entity: climate.living_room
|
||||
- type: tile
|
||||
entity: sensor.living_room_temperature
|
||||
- type: tile
|
||||
entity: sensor.living_room_humidity
|
||||
```
|
||||
|
||||
## Registry Listing Strategy
|
||||
|
||||
Use the list tools first to discover available data before building cards:
|
||||
|
||||
- `area_list`: list areas and filter with `area-id` and `floor`
|
||||
- `device_list`: list devices and filter with `device-id`, `area`, and `floor`
|
||||
- `entity_list`: list entities and filter with `entity-id`, `domain`, `area`, `floor`, `label`, `device`, and `device-class`
|
||||
|
||||
When needed, use `count`, `brief`, and `limit` flags to narrow output and then run a second call with the exact IDs you want to include in the dashboard.
|
||||
|
||||
## Task-Focused Dashboards
|
||||
|
||||
When creating a dashboard focused on a specific task that involves a few devices (e.g., "Home Office", "Coffee Station", "Media Center"), include a **Maintenance section** alongside the primary controls. This section should contain:
|
||||
|
||||
- Battery levels for wireless devices
|
||||
- Signal strength indicators
|
||||
- Firmware update status
|
||||
- Device connectivity states
|
||||
- Any diagnostic entities relevant to the devices
|
||||
|
||||
This approach keeps users informed about the health of the devices supporting their task without cluttering the main interface. When something stops working, the maintenance section provides immediate visibility into potential issues.
|
||||
|
||||
## Respect Entity Categories
|
||||
|
||||
Entities have categories that indicate their intended purpose:
|
||||
|
||||
- **No category (primary)**: Main controls and states meant for regular user interaction
|
||||
- **Diagnostic**: Entities for maintenance and troubleshooting (e.g., signal strength, battery level, firmware version)
|
||||
- **Config**: Configuration entities for device settings (e.g., sensitivity levels, LED brightness)
|
||||
|
||||
When building dashboards:
|
||||
- Group primary entities together for the main user interface
|
||||
- Place diagnostic entities in a separate "Maintenance" or "Diagnostics" section
|
||||
- Config entities typically belong in a dedicated settings area, not the main dashboard
|
||||
|
||||
This separation keeps dashboards clean and prevents users from accidentally changing configuration settings.
|
||||
|
||||
## Tile Card Features for Enhanced Control
|
||||
|
||||
Tile cards support features that provide additional control directly on the card. Consider using tile card features for:
|
||||
|
||||
- **Primary controls**: Light brightness slider, cover position, fan speed
|
||||
- **Frequently used actions**: Toggle switches, quick actions
|
||||
|
||||
Avoid adding features to:
|
||||
- Diagnostic entities
|
||||
- Configuration entities
|
||||
- Entities where simple state display is sufficient
|
||||
|
||||
Tile card features make important controls more accessible and visually prominent.
|
||||
|
||||
```yaml
|
||||
type: tile
|
||||
entity: light.ceiling_lights
|
||||
features:
|
||||
- type: light-brightness
|
||||
```
|
||||
|
||||
Available features: `cover-open-close`, `cover-position`, `cover-tilt`, `cover-tilt-position`, `light-brightness`, `light-color-temp`, `lock-commands`, `lock-open-door`, `media-player-playback`, `media-player-volume-slider`, `media-player-volume-buttons`, `fan-direction`, `fan-oscillate`, `fan-preset-modes`, `fan-speed`, `alarm-modes`, `climate-fan-modes`, `climate-swing-modes`, `climate-swing-horizontal-modes`, `climate-hvac-modes`, `climate-preset-modes`, `counter-actions`, `date-set`, `select-options`, `numeric-input`, `target-humidity`, `target-temperature`, `toggle`, `water-heater-operation-modes`, `humidifier-modes`, `humidifier-toggle`, `vacuum-commands`, `valve-open-close`, `valve-position`, `lawn-mower-commands`, `update-actions`, `trend-graph`, `area-controls`, `bar-gauge`,
|
||||
|
||||
## Specialized Cards for Specific Domains
|
||||
|
||||
### Climate Entities
|
||||
Use the **thermostat card** for climate entities. It provides:
|
||||
- Current and target temperature display
|
||||
- HVAC mode selection
|
||||
- Temperature adjustment controls
|
||||
- A visual representation that users intuitively understand
|
||||
|
||||
```yaml
|
||||
type: thermostat
|
||||
entity: climate.heatpump
|
||||
```
|
||||
|
||||
### Camera and Image Entities
|
||||
Use **picture-entity cards** for camera and image entities:
|
||||
- Hide the state (the image itself is the state)
|
||||
- Hide the name unless the image context is ambiguous (most cameras and images are self-explanatory when viewed)
|
||||
- Let the visual content speak for itself
|
||||
|
||||
```yaml
|
||||
type: picture-entity
|
||||
entity: camera.demo_camera
|
||||
show_state: false
|
||||
show_name: false
|
||||
camera_view: auto
|
||||
fit_mode: cover
|
||||
```
|
||||
|
||||
### Graph Cards
|
||||
|
||||
Sometimes you want to show historical data for an entity. The choice of graph card depends on the type of entity:
|
||||
|
||||
#### Statistics Graph (for sensor entities)
|
||||
Use **statistics-graph** cards when displaying sensor data over time:
|
||||
- Automatically calculates and displays statistics (mean, min, max)
|
||||
- Optimized for numerical sensor data
|
||||
- Better performance for long time ranges
|
||||
|
||||
#### History Graph (for other entity types)
|
||||
Use **history-graph** cards for:
|
||||
- Climate entity history (showing temperature changes alongside HVAC states)
|
||||
- Binary sensor timelines
|
||||
- State-based entities where you want to see state changes over time
|
||||
- Any non-sensor entity where historical data is valuable
|
||||
|
||||
The history graph shows actual state changes as they occurred, which is more appropriate for non-numerical entities.
|
||||
|
||||
## Using Badges for Global Information
|
||||
|
||||
Badges are ideal for displaying global data points that apply to an entire dashboard view. Good candidates include:
|
||||
|
||||
- Area temperature and humidity
|
||||
- Security system status
|
||||
- Weather conditions
|
||||
- Presence/occupancy indicators
|
||||
- General alerts or warnings
|
||||
|
||||
If the information is more specific to a subset of the dashboard, consider adding it to a section header instead of a badge. Badges work best for truly dashboard-wide context.
|
||||
|
||||
```yaml
|
||||
type: entity
|
||||
entity: sensor.temperature
|
||||
```
|
||||
@@ -196,9 +196,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
websocket_api.async_register_command(
|
||||
hass, websocket.websocket_lovelace_delete_config
|
||||
)
|
||||
websocket_api.async_register_command(
|
||||
hass, websocket.websocket_lovelace_generate_dashboard
|
||||
)
|
||||
|
||||
yaml_dashboards = config[DOMAIN].get(CONF_DASHBOARDS, {})
|
||||
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
"""LLM tools for generating Lovelace dashboards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
llm,
|
||||
)
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
API_ID = "lovelace_dashboard_generation"
|
||||
API_NAME = "Lovelace Dashboard Generation"
|
||||
API_PROMPT = """Use the list tools to discover available areas, devices and entities.
|
||||
Always reference real entity_ids from tool results when building dashboard cards.
|
||||
Return dashboard data that includes a top-level `views` array."""
|
||||
|
||||
GENERATE_GUIDELINES = Path(__file__).parent / "GUIDE.md"
|
||||
|
||||
_AREA_LIST_PARAMETERS = vol.Schema(
|
||||
{
|
||||
vol.Optional("area_id"): str,
|
||||
vol.Optional("area-id"): str,
|
||||
vol.Optional("floor"): str,
|
||||
vol.Optional("count", default=False): bool,
|
||||
vol.Optional("brief", default=False): bool,
|
||||
vol.Optional("limit", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
}
|
||||
)
|
||||
|
||||
_DEVICE_LIST_PARAMETERS = vol.Schema(
|
||||
{
|
||||
vol.Optional("device_id"): str,
|
||||
vol.Optional("device-id"): str,
|
||||
vol.Optional("area"): str,
|
||||
vol.Optional("floor"): str,
|
||||
vol.Optional("count", default=False): bool,
|
||||
vol.Optional("brief", default=False): bool,
|
||||
vol.Optional("limit", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
}
|
||||
)
|
||||
|
||||
_ENTITY_LIST_PARAMETERS = vol.Schema(
|
||||
{
|
||||
vol.Optional("entity_id"): str,
|
||||
vol.Optional("entity-id"): str,
|
||||
vol.Optional("domain"): str,
|
||||
vol.Optional("area"): str,
|
||||
vol.Optional("floor"): str,
|
||||
vol.Optional("label"): str,
|
||||
vol.Optional("device"): str,
|
||||
vol.Optional("device_class"): str,
|
||||
vol.Optional("device-class"): str,
|
||||
vol.Optional("count", default=False): bool,
|
||||
vol.Optional("brief", default=False): bool,
|
||||
vol.Optional("limit", default=0): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _tool_str(data: dict[str, Any], *keys: str) -> str | None:
|
||||
"""Extract a string value from alternate parameter names."""
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _entity_device_class(
|
||||
reg_entry: er.RegistryEntry | None, attributes: dict[str, Any]
|
||||
) -> str:
|
||||
"""Resolve device class with the same precedence as hab entity list."""
|
||||
if reg_entry and reg_entry.original_device_class:
|
||||
return reg_entry.original_device_class
|
||||
if reg_entry and reg_entry.device_class:
|
||||
return reg_entry.device_class
|
||||
device_class = attributes.get("device_class")
|
||||
if isinstance(device_class, str):
|
||||
return device_class
|
||||
return ""
|
||||
|
||||
|
||||
def _apply_limit(items: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
|
||||
"""Apply list limit the same way as hab list commands."""
|
||||
if limit > 0 and len(items) > limit:
|
||||
return items[:limit]
|
||||
return items
|
||||
|
||||
|
||||
async def build_generation_instructions(hass: HomeAssistant, prompt: str) -> str:
|
||||
"""Build instructions used for Lovelace dashboard generation."""
|
||||
guide = await hass.async_add_executor_job(GENERATE_GUIDELINES.read_text)
|
||||
|
||||
return (
|
||||
"Generate a Home Assistant Lovelace dashboard configuration.\n"
|
||||
"Return only valid JSON (no markdown and no explanation).\n"
|
||||
"Return a complete dashboard object with a top-level `views` array.\n"
|
||||
"Each view should include useful cards for the user request.\n"
|
||||
"Use the list tools to discover real area, device and entity IDs.\n"
|
||||
"Use real entity IDs discovered from available tools.\n"
|
||||
"Prioritize readable, practical dashboards over decorative layouts.\n\n"
|
||||
f"User request:\n{prompt.strip()}\n\n"
|
||||
f"{guide}"
|
||||
)
|
||||
|
||||
|
||||
class AreaListTool(llm.Tool):
|
||||
"""Tool mirroring `hab area list`."""
|
||||
|
||||
name = "area_list"
|
||||
description = (
|
||||
"List areas with hab-compatible filters: area-id, floor, count, brief, limit."
|
||||
)
|
||||
parameters = _AREA_LIST_PARAMETERS
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the tool."""
|
||||
self._hass = hass
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
tool_input: llm.ToolInput,
|
||||
llm_context: llm.LLMContext,
|
||||
) -> JsonObjectType:
|
||||
"""List areas with hab-compatible output fields."""
|
||||
del hass, llm_context
|
||||
data = cast(dict[str, Any], self.parameters(tool_input.tool_args))
|
||||
area_id_filter = _tool_str(data, "area_id", "area-id")
|
||||
floor_filter = _tool_str(data, "floor")
|
||||
count = cast(bool, data["count"])
|
||||
brief = cast(bool, data["brief"])
|
||||
limit = cast(int, data["limit"])
|
||||
|
||||
area_registry = ar.async_get(self._hass)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for area in area_registry.areas.values():
|
||||
if area_id_filter and area.id != area_id_filter:
|
||||
continue
|
||||
if floor_filter and area.floor_id != floor_filter:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"area_id": area.id,
|
||||
"name": area.name,
|
||||
"floor_id": area.floor_id,
|
||||
"icon": area.icon,
|
||||
"labels": sorted(area.labels),
|
||||
}
|
||||
)
|
||||
|
||||
if count:
|
||||
return {"count": len(result)}
|
||||
|
||||
result = _apply_limit(result, limit)
|
||||
if brief:
|
||||
return {
|
||||
"areas": [
|
||||
{"area_id": area["area_id"], "name": area["name"]}
|
||||
for area in result
|
||||
]
|
||||
}
|
||||
return {"areas": result}
|
||||
|
||||
|
||||
class DeviceListTool(llm.Tool):
|
||||
"""Tool mirroring `hab device list`."""
|
||||
|
||||
name = "device_list"
|
||||
description = (
|
||||
"List devices with hab-compatible filters: device-id, area, floor, count,"
|
||||
" brief, limit."
|
||||
)
|
||||
parameters = _DEVICE_LIST_PARAMETERS
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the tool."""
|
||||
self._hass = hass
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
tool_input: llm.ToolInput,
|
||||
llm_context: llm.LLMContext,
|
||||
) -> JsonObjectType:
|
||||
"""List devices with hab-compatible output fields."""
|
||||
del hass, llm_context
|
||||
data = cast(dict[str, Any], self.parameters(tool_input.tool_args))
|
||||
device_id_filter = _tool_str(data, "device_id", "device-id")
|
||||
area_filter = _tool_str(data, "area")
|
||||
floor_filter = _tool_str(data, "floor")
|
||||
count = cast(bool, data["count"])
|
||||
brief = cast(bool, data["brief"])
|
||||
limit = cast(int, data["limit"])
|
||||
|
||||
area_floor_map: dict[str, str] = {}
|
||||
if floor_filter:
|
||||
area_registry = ar.async_get(self._hass)
|
||||
area_floor_map = {
|
||||
area.id: area.floor_id or ""
|
||||
for area in area_registry.areas.values()
|
||||
if area.id
|
||||
}
|
||||
|
||||
device_registry = dr.async_get(self._hass)
|
||||
result: list[dict[str, Any]] = []
|
||||
for device in device_registry.devices.values():
|
||||
if device_id_filter and device.id != device_id_filter:
|
||||
continue
|
||||
if area_filter and device.area_id != area_filter:
|
||||
continue
|
||||
if floor_filter:
|
||||
if not device.area_id:
|
||||
continue
|
||||
if area_floor_map.get(device.area_id) != floor_filter:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": device.id,
|
||||
"name": device.name,
|
||||
"manufacturer": device.manufacturer,
|
||||
"model": device.model,
|
||||
"area_id": device.area_id,
|
||||
}
|
||||
)
|
||||
|
||||
if count:
|
||||
return {"count": len(result)}
|
||||
|
||||
result = _apply_limit(result, limit)
|
||||
if brief:
|
||||
return {
|
||||
"devices": [{"id": item["id"], "name": item["name"]} for item in result]
|
||||
}
|
||||
return {"devices": result}
|
||||
|
||||
|
||||
class EntityListTool(llm.Tool):
|
||||
"""Tool mirroring `hab entity list`."""
|
||||
|
||||
name = "entity_list"
|
||||
description = (
|
||||
"List entities with hab-compatible filters: entity-id, domain, area, floor,"
|
||||
" label, device, device-class, count, brief, limit."
|
||||
)
|
||||
parameters = _ENTITY_LIST_PARAMETERS
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the tool."""
|
||||
self._hass = hass
|
||||
|
||||
async def async_call(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
tool_input: llm.ToolInput,
|
||||
llm_context: llm.LLMContext,
|
||||
) -> JsonObjectType:
|
||||
"""List entities with hab-compatible output fields."""
|
||||
del hass, llm_context
|
||||
data = cast(dict[str, Any], self.parameters(tool_input.tool_args))
|
||||
entity_id_filter = _tool_str(data, "entity_id", "entity-id")
|
||||
domain_filter = _tool_str(data, "domain")
|
||||
area_filter = _tool_str(data, "area")
|
||||
floor_filter = _tool_str(data, "floor")
|
||||
label_filter = _tool_str(data, "label")
|
||||
device_filter = _tool_str(data, "device")
|
||||
device_class_filter = _tool_str(data, "device_class", "device-class")
|
||||
count = cast(bool, data["count"])
|
||||
brief = cast(bool, data["brief"])
|
||||
limit = cast(int, data["limit"])
|
||||
|
||||
area_floor_map: dict[str, str] = {}
|
||||
if floor_filter:
|
||||
area_registry = ar.async_get(self._hass)
|
||||
area_floor_map = {
|
||||
area.id: area.floor_id or ""
|
||||
for area in area_registry.areas.values()
|
||||
if area.id
|
||||
}
|
||||
|
||||
entity_registry = er.async_get(self._hass)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for state in self._hass.states.async_all():
|
||||
entity_id = state.entity_id
|
||||
|
||||
if entity_id_filter and entity_id != entity_id_filter:
|
||||
continue
|
||||
|
||||
if domain_filter and state.domain != domain_filter:
|
||||
continue
|
||||
|
||||
reg_entry = entity_registry.async_get(entity_id)
|
||||
|
||||
if device_filter:
|
||||
if reg_entry is None or reg_entry.device_id != device_filter:
|
||||
continue
|
||||
|
||||
if area_filter:
|
||||
if reg_entry is None or reg_entry.area_id != area_filter:
|
||||
continue
|
||||
|
||||
if floor_filter:
|
||||
if reg_entry is None or not reg_entry.area_id:
|
||||
continue
|
||||
if area_floor_map.get(reg_entry.area_id) != floor_filter:
|
||||
continue
|
||||
|
||||
if label_filter:
|
||||
if reg_entry is None or label_filter not in reg_entry.labels:
|
||||
continue
|
||||
|
||||
friendly_name = state.attributes.get("friendly_name")
|
||||
if not isinstance(friendly_name, str):
|
||||
friendly_name = ""
|
||||
|
||||
device_class = _entity_device_class(reg_entry, state.attributes)
|
||||
if device_class_filter and device_class != device_class_filter:
|
||||
continue
|
||||
|
||||
result.append(
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"state": state.state,
|
||||
"name": friendly_name,
|
||||
"area_id": reg_entry.area_id if reg_entry else "",
|
||||
"device_id": reg_entry.device_id if reg_entry else "",
|
||||
"device_class": device_class,
|
||||
"labels": sorted(reg_entry.labels) if reg_entry else [],
|
||||
"disabled": reg_entry.disabled_by is not None
|
||||
if reg_entry
|
||||
else False,
|
||||
}
|
||||
)
|
||||
|
||||
if count:
|
||||
return {"count": len(result)}
|
||||
|
||||
result = _apply_limit(result, limit)
|
||||
if brief:
|
||||
return {
|
||||
"entities": [
|
||||
{"entity_id": item["entity_id"], "name": item["name"]}
|
||||
for item in result
|
||||
]
|
||||
}
|
||||
return {"entities": result}
|
||||
|
||||
|
||||
class LovelaceDashboardGenerationAPI(llm.API):
|
||||
"""LLM API for Lovelace dashboard generation."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the API."""
|
||||
super().__init__(hass=hass, id=API_ID, name=API_NAME)
|
||||
|
||||
async def async_get_api_instance(
|
||||
self, llm_context: llm.LLMContext
|
||||
) -> llm.APIInstance:
|
||||
"""Return the API instance."""
|
||||
return llm.APIInstance(
|
||||
api=self,
|
||||
api_prompt=API_PROMPT,
|
||||
llm_context=llm_context,
|
||||
tools=[
|
||||
AreaListTool(self.hass),
|
||||
DeviceListTool(self.hass),
|
||||
EntityListTool(self.hass),
|
||||
],
|
||||
)
|
||||
@@ -8,12 +8,11 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ai_task, websocket_api
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.json import json_fragment
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .const import (
|
||||
CONF_RESOURCE_MODE,
|
||||
@@ -23,7 +22,6 @@ from .const import (
|
||||
ConfigNotFound,
|
||||
)
|
||||
from .dashboard import LovelaceConfig
|
||||
from .llm import LovelaceDashboardGenerationAPI, build_generation_instructions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .resources import ResourceStorageCollection
|
||||
@@ -186,93 +184,3 @@ async def websocket_lovelace_delete_config(
|
||||
) -> None:
|
||||
"""Delete Lovelace UI configuration."""
|
||||
await config.async_delete()
|
||||
|
||||
|
||||
def _coerce_generated_dashboard(data: Any) -> dict[str, Any]:
|
||||
"""Coerce AI output into a dashboard config object."""
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
|
||||
if not isinstance(data, str):
|
||||
raise HomeAssistantError("Generated dashboard must be a valid JSON object")
|
||||
|
||||
candidates = [data.strip()]
|
||||
|
||||
if "```" in data:
|
||||
for block in data.split("```"):
|
||||
candidate = block.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
if candidate.casefold().startswith("json"):
|
||||
candidate = candidate[4:].strip()
|
||||
candidates.append(candidate)
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
parsed = json_loads(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
|
||||
raise HomeAssistantError("Generated dashboard must be a valid JSON object")
|
||||
|
||||
|
||||
def _validate_generated_dashboard(data: Any) -> dict[str, Any]:
|
||||
"""Validate generated dashboard response."""
|
||||
if not isinstance(data, dict):
|
||||
raise HomeAssistantError("Generated dashboard must be an object")
|
||||
|
||||
views = data.get("views")
|
||||
if not isinstance(views, list) or not views:
|
||||
raise HomeAssistantError(
|
||||
"Generated dashboard must include at least one view in `views`"
|
||||
)
|
||||
|
||||
if not all(isinstance(view, dict) for view in views):
|
||||
raise HomeAssistantError("Each dashboard view must be an object")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "lovelace/config/generate",
|
||||
vol.Required("prompt"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_lovelace_generate_dashboard(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Generate a Lovelace dashboard configuration from a prompt."""
|
||||
if ai_task.DOMAIN not in hass.config.components:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"error",
|
||||
"AI Task integration is not available. Configure AI Task first.",
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="lovelace_dashboard_generation",
|
||||
instructions=await build_generation_instructions(hass, msg["prompt"]),
|
||||
llm_api=LovelaceDashboardGenerationAPI(hass),
|
||||
)
|
||||
config = _validate_generated_dashboard(_coerce_generated_dashboard(result.data))
|
||||
except HomeAssistantError as err:
|
||||
connection.send_error(msg["id"], "error", str(err))
|
||||
return
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"conversation_id": result.conversation_id,
|
||||
"config": config,
|
||||
},
|
||||
)
|
||||
|
||||
20
homeassistant/components/lunatone/diagnostics.py
Normal file
20
homeassistant/components/lunatone/diagnostics.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Diagnostics support for Lunatone integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import LunatoneConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: LunatoneConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"info": entry.runtime_data.coordinator_info.data.model_dump(),
|
||||
"devices": [
|
||||
v.data.model_dump()
|
||||
for v in entry.runtime_data.coordinator_devices.data.values()
|
||||
],
|
||||
}
|
||||
@@ -51,7 +51,7 @@ rules:
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: todo
|
||||
comment: Discovery not yet supported
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from mastodon import Mastodon
|
||||
from mastodon.Mastodon import MastodonAPIError, MediaAttachment
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import service
|
||||
|
||||
from .const import (
|
||||
ATTR_CONTENT_WARNING,
|
||||
@@ -53,30 +53,15 @@ SERVICE_POST_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MastodonConfigEntry:
|
||||
"""Get the Mastodon config entry."""
|
||||
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": DOMAIN},
|
||||
)
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": entry.title},
|
||||
)
|
||||
return cast(MastodonConfigEntry, entry)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the Mastodon integration."""
|
||||
|
||||
async def async_post(call: ServiceCall) -> ServiceResponse:
|
||||
"""Post a status."""
|
||||
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
|
||||
entry: MastodonConfigEntry = service.async_get_config_entry(
|
||||
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
|
||||
)
|
||||
client = entry.runtime_data.client
|
||||
|
||||
status: str = call.data[ATTR_STATUS]
|
||||
|
||||
@@ -72,12 +72,6 @@
|
||||
"idempotency_key_too_short": {
|
||||
"message": "Idempotency key must be at least 4 characters long."
|
||||
},
|
||||
"integration_not_found": {
|
||||
"message": "Integration \"{target}\" not found in registry."
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
},
|
||||
"not_whitelisted_directory": {
|
||||
"message": "{media} is not a whitelisted directory."
|
||||
},
|
||||
|
||||
@@ -251,6 +251,18 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeState,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="SmokeCoAlarmCOStateSensor",
|
||||
device_class=BinarySensorDeviceClass.CO,
|
||||
device_to_ha=lambda x: (
|
||||
x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.SmokeCoAlarm.Attributes.COState,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user