mirror of
https://github.com/home-assistant/core.git
synced 2026-03-03 14:26:59 +01:00
Compare commits
194 Commits
gen-dashbo
...
danielhive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d289565e | ||
|
|
d7e0f4e5c3 | ||
|
|
0fea830e04 | ||
|
|
44521606ec | ||
|
|
47a501cfd8 | ||
|
|
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 | ||
|
|
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 | ||
|
|
5b8ba86fa8 | ||
|
|
0bdb653b55 | ||
|
|
913fd3a981 | ||
|
|
11c4507a16 | ||
|
|
f8e4d7d97a | ||
|
|
434d032abd | ||
|
|
33ac5b78d5 | ||
|
|
3b60ebd7f7 | ||
|
|
ec34a209ad | ||
|
|
83f3b4a170 | ||
|
|
c3ab65b5a5 | ||
|
|
0237a11d4b | ||
|
|
2b9854e412 | ||
|
|
9c780246aa | ||
|
|
314ebc90ff | ||
|
|
05c4c15d1f | ||
|
|
3f2c71ad6b | ||
|
|
c9670b4bd2 | ||
|
|
8e16b1004e | ||
|
|
3a32f87a7f | ||
|
|
92eb2406be | ||
|
|
492c2cec3e | ||
|
|
b7c6e8d68a | ||
|
|
205bc0456f | ||
|
|
5aa32491c8 | ||
|
|
dc2cd2246b | ||
|
|
181037820b | ||
|
|
6cf15bf70c | ||
|
|
5a34c31e42 | ||
|
|
9dcc86f12e | ||
|
|
04429a6eef | ||
|
|
51e2506afb | ||
|
|
e49e5c7c40 | ||
|
|
b8dfc523da | ||
|
|
a25fbf57ef | ||
|
|
dac22002b0 | ||
|
|
e61f00a3ae | ||
|
|
14a67c6b5d | ||
|
|
90ae81f02b | ||
|
|
a741f214da | ||
|
|
21d0bd3ce2 | ||
|
|
d9c1f4850a | ||
|
|
335994af7e |
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.*
|
||||
|
||||
16
CODEOWNERS
generated
16
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,10 @@ 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/homevolt/ @danielhiversen
|
||||
/tests/components/homevolt/ @danielhiversen
|
||||
/homeassistant/components/homewizard/ @DCSBL
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
@@ -802,6 +806,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 +1084,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:
|
||||
|
||||
@@ -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}."
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -936,6 +1012,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,
|
||||
|
||||
46
homeassistant/components/homevolt/__init__.py
Normal file
46
homeassistant/components/homevolt/__init__.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""The Homevolt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homevolt import Homevolt
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
|
||||
"""Set up Homevolt from a config entry."""
|
||||
host: str = entry.data[CONF_HOST]
|
||||
password: str | None = entry.data.get(CONF_PASSWORD)
|
||||
|
||||
websession = async_get_clientsession(hass)
|
||||
client = Homevolt(host, password, websession=websession)
|
||||
|
||||
coordinator = HomevoltDataUpdateCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
await entry.runtime_data.client.close_connection()
|
||||
|
||||
return unload_ok
|
||||
119
homeassistant/components/homevolt/config_flow.py
Normal file
119
homeassistant/components/homevolt/config_flow.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Config flow for the Homevolt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homevolt import Homevolt, HomevoltAuthenticationError, HomevoltConnectionError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_CREDENTIALS_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Homevolt."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._host: str | None = None
|
||||
|
||||
async def check_status(self, client: Homevolt) -> dict[str, str]:
|
||||
"""Check connection status and return errors if any."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await client.update_info()
|
||||
except HomevoltAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except HomevoltConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Error occurred while connecting to the Homevolt battery")
|
||||
errors["base"] = "unknown"
|
||||
return errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
host = user_input[CONF_HOST]
|
||||
password = None
|
||||
websession = async_get_clientsession(self.hass)
|
||||
client = Homevolt(host, password, websession=websession)
|
||||
errors = await self.check_status(client)
|
||||
if errors.get("base") == "invalid_auth":
|
||||
self._host = host
|
||||
return await self.async_step_credentials()
|
||||
|
||||
if not errors:
|
||||
device_id = client.unique_id
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title="Homevolt",
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PASSWORD: None,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_credentials(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the credentials step."""
|
||||
errors: dict[str, str] = {}
|
||||
assert self._host is not None
|
||||
|
||||
if user_input is not None:
|
||||
password = user_input[CONF_PASSWORD]
|
||||
websession = async_get_clientsession(self.hass)
|
||||
client = Homevolt(self._host, password, websession=websession)
|
||||
errors = await self.check_status(client)
|
||||
|
||||
if not errors:
|
||||
device_id = client.unique_id
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title="Homevolt",
|
||||
data={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PASSWORD: password,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="credentials",
|
||||
data_schema=STEP_CREDENTIALS_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"host": self._host},
|
||||
)
|
||||
9
homeassistant/components/homevolt/const.py
Normal file
9
homeassistant/components/homevolt/const.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Constants for the Homevolt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "homevolt"
|
||||
MANUFACTURER = "Homevolt"
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
56
homeassistant/components/homevolt/coordinator.py
Normal file
56
homeassistant/components/homevolt/coordinator.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Data update coordinator for Homevolt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homevolt import (
|
||||
Homevolt,
|
||||
HomevoltAuthenticationError,
|
||||
HomevoltConnectionError,
|
||||
HomevoltError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
type HomevoltConfigEntry = ConfigEntry[HomevoltDataUpdateCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomevoltDataUpdateCoordinator(DataUpdateCoordinator[Homevolt]):
|
||||
"""Class to manage fetching Homevolt data."""
|
||||
|
||||
config_entry: HomevoltConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
client: Homevolt,
|
||||
) -> None:
|
||||
"""Initialize the Homevolt coordinator."""
|
||||
self.client = client
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> Homevolt:
|
||||
"""Fetch data from the Homevolt API."""
|
||||
try:
|
||||
await self.client.update_info()
|
||||
except HomevoltAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (HomevoltConnectionError, HomevoltError) as err:
|
||||
raise UpdateFailed(f"Error communicating with device: {err}") from err
|
||||
|
||||
return self.client
|
||||
64
homeassistant/components/homevolt/entity.py
Normal file
64
homeassistant/components/homevolt/entity.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Shared entity helpers for Homevolt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError, HomevoltError
|
||||
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import HomevoltDataUpdateCoordinator
|
||||
|
||||
|
||||
class HomevoltEntity(CoordinatorEntity[HomevoltDataUpdateCoordinator]):
|
||||
"""Base Homevolt entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: HomevoltDataUpdateCoordinator, device_identifier: str
|
||||
) -> None:
|
||||
"""Initialize the Homevolt entity."""
|
||||
super().__init__(coordinator)
|
||||
device_id = coordinator.data.unique_id
|
||||
device_metadata = coordinator.data.device_metadata.get(device_identifier)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{device_id}_{device_identifier}")},
|
||||
configuration_url=coordinator.client.base_url,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=device_metadata.model if device_metadata else None,
|
||||
name=device_metadata.name if device_metadata else None,
|
||||
)
|
||||
|
||||
|
||||
def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
|
||||
func: Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate Homevolt calls to handle exceptions."""
|
||||
|
||||
async def handler(
|
||||
self: _HomevoltEntityT, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except HomevoltAuthenticationError as error:
|
||||
raise ConfigEntryAuthFailed("Authentication failed") from error
|
||||
except HomevoltConnectionError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
except HomevoltError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
|
||||
return handler
|
||||
11
homeassistant/components/homevolt/manifest.json
Normal file
11
homeassistant/components/homevolt/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "homevolt",
|
||||
"name": "Homevolt",
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homevolt",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["homevolt==0.4.3"]
|
||||
}
|
||||
160
homeassistant/components/homevolt/number.py
Normal file
160
homeassistant/components/homevolt/number.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Support for Homevolt number entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
from .entity import HomevoltEntity, homevolt_exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0 # Coordinator-based updates
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomevoltNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes a Homevolt number entity."""
|
||||
|
||||
available_modes: list[int] | None = None # None means available in all modes
|
||||
|
||||
def get_value(self, coordinator: HomevoltDataUpdateCoordinator) -> float | None:
|
||||
"""Get the value from the coordinator based on the key."""
|
||||
return coordinator.client.schedule.get(self.key)
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: tuple[HomevoltNumberEntityDescription, ...] = (
|
||||
HomevoltNumberEntityDescription(
|
||||
key="setpoint",
|
||||
translation_key="setpoint",
|
||||
device_class=NumberDeviceClass.POWER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
native_min_value=0,
|
||||
native_max_value=7000,
|
||||
native_step=1,
|
||||
available_modes=[1, 2, 7, 8], # Inverter/solar charge/discharge modes
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="max_charge",
|
||||
translation_key="max_charge",
|
||||
device_class=NumberDeviceClass.POWER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
native_min_value=0,
|
||||
native_max_value=7000,
|
||||
native_step=1,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="max_discharge",
|
||||
translation_key="max_discharge",
|
||||
device_class=NumberDeviceClass.POWER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
native_min_value=0,
|
||||
native_max_value=7000,
|
||||
native_step=1,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="min_soc",
|
||||
translation_key="min_soc",
|
||||
device_class=NumberDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="max_soc",
|
||||
translation_key="max_soc",
|
||||
device_class=NumberDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
native_min_value=0,
|
||||
native_max_value=100,
|
||||
native_step=1,
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="grid_import_limit",
|
||||
translation_key="grid_import_limit",
|
||||
device_class=NumberDeviceClass.POWER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
native_min_value=0,
|
||||
native_max_value=7000,
|
||||
native_step=1,
|
||||
available_modes=[3, 5], # Grid charge modes
|
||||
),
|
||||
HomevoltNumberEntityDescription(
|
||||
key="grid_export_limit",
|
||||
translation_key="grid_export_limit",
|
||||
device_class=NumberDeviceClass.POWER,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
native_min_value=0,
|
||||
native_max_value=7000,
|
||||
native_step=1,
|
||||
available_modes=[4, 5], # Grid discharge modes
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Homevolt number entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
HomevoltNumber(coordinator, description) for description in NUMBER_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class HomevoltNumber(HomevoltEntity, NumberEntity):
|
||||
"""Representation of a Homevolt number entity."""
|
||||
|
||||
entity_description: HomevoltNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomevoltDataUpdateCoordinator,
|
||||
description: HomevoltNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number entity."""
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}"
|
||||
device_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, f"ems_{device_id}")
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available based on current mode."""
|
||||
if not super().available:
|
||||
return False
|
||||
|
||||
if self.entity_description.available_modes is not None:
|
||||
current_mode = self.coordinator.client.schedule_mode
|
||||
if current_mode not in self.entity_description.available_modes:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
return self.entity_description.get_value(self.coordinator)
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value."""
|
||||
kwargs = {self.entity_description.key: int(value)}
|
||||
await self.coordinator.client.set_battery_mode(**kwargs)
|
||||
await self.coordinator.async_request_refresh()
|
||||
70
homeassistant/components/homevolt/quality_scale.yaml
Normal file
70
homeassistant/components/homevolt/quality_scale.yaml
Normal file
@@ -0,0 +1,70 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom 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: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Local_polling without events
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration does not have an options flow.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
51
homeassistant/components/homevolt/select.py
Normal file
51
homeassistant/components/homevolt/select.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Support for Homevolt select entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homevolt.const import SCHEDULE_TYPE
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
from .entity import HomevoltEntity, homevolt_exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0 # Coordinator-based updates
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Homevolt select entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([HomevoltModeSelect(coordinator)])
|
||||
|
||||
|
||||
class HomevoltModeSelect(HomevoltEntity, SelectEntity):
|
||||
"""Select entity for battery operational mode."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "battery_mode"
|
||||
_attr_options = list(SCHEDULE_TYPE.values())
|
||||
|
||||
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
|
||||
"""Initialize the select entity."""
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}_battery_mode"
|
||||
device_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, f"ems_{device_id}")
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current selected mode."""
|
||||
mode_int = self.coordinator.client.schedule_mode
|
||||
return SCHEDULE_TYPE.get(mode_int, "idle")
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected mode."""
|
||||
await self.coordinator.client.set_battery_mode(mode=option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
365
homeassistant/components/homevolt/sensor.py
Normal file
365
homeassistant/components/homevolt/sensor.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Support for Homevolt sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0 # Coordinator-based updates
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="available_charging_energy",
|
||||
translation_key="available_charging_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="available_charging_power",
|
||||
translation_key="available_charging_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="available_discharge_energy",
|
||||
translation_key="available_discharge_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="available_discharge_power",
|
||||
translation_key="available_discharge_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="rssi",
|
||||
translation_key="rssi",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="average_rssi",
|
||||
translation_key="average_rssi",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="charge_cycles",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement="cycles",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_exported",
|
||||
translation_key="energy_exported",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="energy_imported",
|
||||
translation_key="energy_imported",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="exported_energy",
|
||||
translation_key="exported_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="frequency",
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="imported_energy",
|
||||
translation_key="imported_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l1_current",
|
||||
translation_key="l1_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l1_l2_voltage",
|
||||
translation_key="l1_l2_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l1_power",
|
||||
translation_key="l1_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l1_voltage",
|
||||
translation_key="l1_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l2_current",
|
||||
translation_key="l2_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l2_l3_voltage",
|
||||
translation_key="l2_l3_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l2_power",
|
||||
translation_key="l2_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l2_voltage",
|
||||
translation_key="l2_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l3_current",
|
||||
translation_key="l3_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l3_l1_voltage",
|
||||
translation_key="l3_l1_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l3_power",
|
||||
translation_key="l3_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="l3_voltage",
|
||||
translation_key="l3_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="schedule_id",
|
||||
translation_key="schedule_id",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="schedule_max_discharge",
|
||||
translation_key="schedule_max_discharge",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="schedule_max_power",
|
||||
translation_key="schedule_max_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="schedule_power_setpoint",
|
||||
translation_key="schedule_power_setpoint",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="schedule_type",
|
||||
translation_key="schedule_type",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"idle",
|
||||
"inverter_charge",
|
||||
"inverter_discharge",
|
||||
"grid_charge",
|
||||
"grid_discharge",
|
||||
"grid_charge_discharge",
|
||||
"frequency_reserve",
|
||||
"solar_charge",
|
||||
"solar_charge_discharge",
|
||||
"full_solar_export",
|
||||
],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="state_of_charge",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="system_temperature",
|
||||
translation_key="system_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="tmax",
|
||||
translation_key="tmax",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="tmin",
|
||||
translation_key="tmin",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Homevolt sensor."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[HomevoltSensor] = []
|
||||
sensors_by_key = {sensor.key: sensor for sensor in SENSORS}
|
||||
for sensor_key, sensor in coordinator.data.sensors.items():
|
||||
if (description := sensors_by_key.get(sensor.type)) is None:
|
||||
_LOGGER.warning("Unsupported sensor '%s' found during setup", sensor)
|
||||
continue
|
||||
entities.append(
|
||||
HomevoltSensor(
|
||||
description,
|
||||
coordinator,
|
||||
sensor_key,
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of a Homevolt sensor."""
|
||||
|
||||
entity_description: SensorEntityDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: SensorEntityDescription,
|
||||
coordinator: HomevoltDataUpdateCoordinator,
|
||||
sensor_key: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
unique_id = coordinator.data.unique_id
|
||||
self._attr_unique_id = f"{unique_id}_{sensor_key}"
|
||||
sensor_data = coordinator.data.sensors[sensor_key]
|
||||
self._sensor_key = sensor_key
|
||||
|
||||
device_metadata = coordinator.data.device_metadata.get(
|
||||
sensor_data.device_identifier
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{unique_id}_{sensor_data.device_identifier}")},
|
||||
configuration_url=coordinator.client.base_url,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=device_metadata.model if device_metadata else None,
|
||||
name=device_metadata.name if device_metadata else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._sensor_key in self.coordinator.data.sensors
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the native value of the sensor."""
|
||||
return self.coordinator.data.sensors[self._sensor_key].value
|
||||
200
homeassistant/components/homevolt/strings.json
Normal file
200
homeassistant/components/homevolt/strings.json
Normal file
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"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%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"credentials": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The local password configured for your Homevolt battery."
|
||||
},
|
||||
"description": "This device requires a password to connect. Please enter the password for {host}."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address or hostname of your Homevolt battery on your local network."
|
||||
},
|
||||
"description": "Connect Home Assistant to your Homevolt battery over the local network."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"grid_export_limit": {
|
||||
"name": "Grid export limit"
|
||||
},
|
||||
"grid_import_limit": {
|
||||
"name": "Grid import limit"
|
||||
},
|
||||
"max_charge": {
|
||||
"name": "Max charge power"
|
||||
},
|
||||
"max_discharge": {
|
||||
"name": "Max discharge power"
|
||||
},
|
||||
"max_soc": {
|
||||
"name": "Maximum state of charge"
|
||||
},
|
||||
"min_soc": {
|
||||
"name": "Minimum state of charge"
|
||||
},
|
||||
"setpoint": {
|
||||
"name": "Power setpoint"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"battery_mode": {
|
||||
"name": "Battery mode",
|
||||
"state": {
|
||||
"frequency_reserve": "Frequency reserve",
|
||||
"full_solar_export": "Full solar export",
|
||||
"grid_charge": "Grid charge",
|
||||
"grid_charge_discharge": "Grid charge/discharge",
|
||||
"grid_discharge": "Grid discharge",
|
||||
"idle": "Idle",
|
||||
"inverter_charge": "Inverter charge",
|
||||
"inverter_discharge": "Inverter discharge",
|
||||
"solar_charge": "Solar charge",
|
||||
"solar_charge_discharge": "Solar charge/discharge"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"available_charging_energy": {
|
||||
"name": "Available charging energy"
|
||||
},
|
||||
"available_charging_power": {
|
||||
"name": "Available charging power"
|
||||
},
|
||||
"available_discharge_energy": {
|
||||
"name": "Available discharge energy"
|
||||
},
|
||||
"available_discharge_power": {
|
||||
"name": "Available discharge power"
|
||||
},
|
||||
"average_rssi": {
|
||||
"name": "Average RSSI"
|
||||
},
|
||||
"battery_state_of_charge": {
|
||||
"name": "Battery state of charge"
|
||||
},
|
||||
"charge_cycles": {
|
||||
"unit_of_measurement": "cycles"
|
||||
},
|
||||
"energy_exported": {
|
||||
"name": "Energy exported"
|
||||
},
|
||||
"energy_imported": {
|
||||
"name": "Energy imported"
|
||||
},
|
||||
"exported_energy": {
|
||||
"name": "Exported energy"
|
||||
},
|
||||
"imported_energy": {
|
||||
"name": "Imported energy"
|
||||
},
|
||||
"l1_current": {
|
||||
"name": "L1 current"
|
||||
},
|
||||
"l1_l2_voltage": {
|
||||
"name": "L1-L2 voltage"
|
||||
},
|
||||
"l1_power": {
|
||||
"name": "L1 power"
|
||||
},
|
||||
"l1_voltage": {
|
||||
"name": "L1 voltage"
|
||||
},
|
||||
"l2_current": {
|
||||
"name": "L2 current"
|
||||
},
|
||||
"l2_l3_voltage": {
|
||||
"name": "L2-L3 voltage"
|
||||
},
|
||||
"l2_power": {
|
||||
"name": "L2 power"
|
||||
},
|
||||
"l2_voltage": {
|
||||
"name": "L2 voltage"
|
||||
},
|
||||
"l3_current": {
|
||||
"name": "L3 current"
|
||||
},
|
||||
"l3_l1_voltage": {
|
||||
"name": "L3-L1 voltage"
|
||||
},
|
||||
"l3_power": {
|
||||
"name": "L3 power"
|
||||
},
|
||||
"l3_voltage": {
|
||||
"name": "L3 voltage"
|
||||
},
|
||||
"power": {
|
||||
"name": "Power"
|
||||
},
|
||||
"rssi": {
|
||||
"name": "RSSI"
|
||||
},
|
||||
"schedule_id": {
|
||||
"name": "Schedule ID"
|
||||
},
|
||||
"schedule_max_discharge": {
|
||||
"name": "Schedule max discharge"
|
||||
},
|
||||
"schedule_max_power": {
|
||||
"name": "Schedule max power"
|
||||
},
|
||||
"schedule_power_setpoint": {
|
||||
"name": "Schedule power setpoint"
|
||||
},
|
||||
"schedule_type": {
|
||||
"name": "Schedule type",
|
||||
"state": {
|
||||
"frequency_reserve": "Frequency reserve",
|
||||
"full_solar_export": "Full solar export",
|
||||
"grid_charge": "Grid charge",
|
||||
"grid_charge_discharge": "Grid charge/discharge",
|
||||
"grid_discharge": "Grid discharge",
|
||||
"idle": "Idle",
|
||||
"inverter_charge": "Inverter charge",
|
||||
"inverter_discharge": "Inverter discharge",
|
||||
"solar_charge": "Solar charge",
|
||||
"solar_charge_discharge": "Solar charge/discharge"
|
||||
}
|
||||
},
|
||||
"system_temperature": {
|
||||
"name": "System temperature"
|
||||
},
|
||||
"tmax": {
|
||||
"name": "Maximum temperature"
|
||||
},
|
||||
"tmin": {
|
||||
"name": "Minimum temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"local_mode": {
|
||||
"name": "Local mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "Failed to communicate with Homevolt: {error}"
|
||||
},
|
||||
"unknown_error": {
|
||||
"message": "An unknown error occurred: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
55
homeassistant/components/homevolt/switch.py
Normal file
55
homeassistant/components/homevolt/switch.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Support for Homevolt switch entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
from .entity import HomevoltEntity, homevolt_exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0 # Coordinator-based updates
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Homevolt switch entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([HomevoltLocalModeSwitch(coordinator)])
|
||||
|
||||
|
||||
class HomevoltLocalModeSwitch(HomevoltEntity, SwitchEntity):
|
||||
"""Switch entity for Homevolt local mode."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "local_mode"
|
||||
|
||||
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
|
||||
"""Initialize the switch entity."""
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}_local_mode"
|
||||
device_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, f"ems_{device_id}")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the local mode state."""
|
||||
return self.coordinator.client.local_mode_enabled
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Enable local mode."""
|
||||
await self.coordinator.client.enable_local_mode()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Disable local mode."""
|
||||
await self.coordinator.client.disable_local_mode()
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -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()
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
|
||||
@@ -435,9 +435,9 @@ class MatterLight(MatterEntity, LightEntity):
|
||||
and color_mode == ColorMode.XY
|
||||
):
|
||||
self._attr_xy_color = self._get_xy_color()
|
||||
elif self._attr_color_temp_kelvin is not None:
|
||||
elif self._supports_color_temperature:
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
elif self._attr_brightness is not None:
|
||||
elif self._supports_brightness:
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
else:
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
|
||||
@@ -284,6 +284,7 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
featuremap_contains=(clusters.Thermostat.Bitmaps.Feature.kSetback),
|
||||
),
|
||||
# Eve temperature offset with higher min/max
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
@@ -303,7 +304,27 @@ DISCOVERY_SCHEMAS = [
|
||||
required_attributes=(
|
||||
clusters.Thermostat.Attributes.LocalTemperatureCalibration,
|
||||
),
|
||||
vendor_id=(4874,),
|
||||
vendor_id=(4874,), # Eve Systems
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
key="TemperatureOffset",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="temperature_offset",
|
||||
native_max_value=25, # Matter 1.3 limit
|
||||
native_min_value=-25, # Matter 1.3 limit
|
||||
native_step=0.5,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_to_ha=lambda x: None if x is None else x / 10,
|
||||
ha_to_device=lambda x: round(x * 10),
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(
|
||||
clusters.Thermostat.Attributes.LocalTemperatureCalibration,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user