Compare commits

..

1 Commits

Author SHA1 Message Date
Erik
998971260f Prevent test sessions from overwriting each other's files 2026-02-13 16:18:43 +01:00
330 changed files with 1436 additions and 14575 deletions

View File

@@ -22,7 +22,6 @@ base_platforms: &base_platforms
- homeassistant/components/calendar/**
- homeassistant/components/camera/**
- homeassistant/components/climate/**
- homeassistant/components/conversation/**
- homeassistant/components/cover/**
- homeassistant/components/date/**
- homeassistant/components/datetime/**
@@ -54,7 +53,6 @@ base_platforms: &base_platforms
- homeassistant/components/update/**
- homeassistant/components/vacuum/**
- homeassistant/components/valve/**
- homeassistant/components/wake_word/**
- homeassistant/components/water_heater/**
- homeassistant/components/weather/**
@@ -72,6 +70,7 @@ components: &components
- homeassistant/components/cloud/**
- homeassistant/components/config/**
- homeassistant/components/configurator/**
- homeassistant/components/conversation/**
- homeassistant/components/demo/**
- homeassistant/components/device_automation/**
- homeassistant/components/dhcp/**

View File

@@ -60,13 +60,7 @@
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [

View File

@@ -225,7 +225,7 @@ jobs:
- name: Build base image
id: build
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
@@ -530,7 +530,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -543,7 +543,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

View File

@@ -287,7 +287,6 @@ 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.*
@@ -364,6 +363,7 @@ homeassistant.components.my.*
homeassistant.components.mysensors.*
homeassistant.components.myuplink.*
homeassistant.components.nam.*
homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.*
homeassistant.components.neato.*
homeassistant.components.nest.*

8
CODEOWNERS generated
View File

@@ -719,8 +719,6 @@ build.json @home-assistant/supervisor
/tests/components/homematic/ @pvizeli
/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
@@ -806,8 +804,6 @@ 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
@@ -1084,8 +1080,8 @@ build.json @home-assistant/supervisor
/tests/components/nam/ @bieniu
/homeassistant/components/namecheapdns/ @tr4nt0r
/tests/components/namecheapdns/ @tr4nt0r
/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul

View File

@@ -29,24 +29,3 @@ 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",
}

View File

@@ -1,8 +1,5 @@
{
"services": {
"send_info_skill": {
"service": "mdi:information"
},
"send_sound": {
"service": "mdi:cast-audio"
},

View File

@@ -1,6 +1,5 @@
"""Support for services."""
from aioamazondevices.const.metadata import ALEXA_INFO_SKILLS
from aioamazondevices.const.sounds import SOUNDS_LIST
import voluptuous as vol
@@ -10,15 +9,13 @@ 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, INFO_SKILLS_MAPPING
from .const import DOMAIN
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(
{
@@ -32,12 +29,6 @@ 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
@@ -95,17 +86,6 @@ 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:
@@ -118,11 +98,6 @@ 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."""
@@ -137,10 +112,5 @@ 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)

View File

@@ -67,36 +67,3 @@ 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

View File

@@ -102,35 +102,11 @@
"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",
@@ -178,20 +154,6 @@
}
},
"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": {

View File

@@ -3,6 +3,7 @@
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,
@@ -12,7 +13,6 @@ 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,6 +37,23 @@ 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] = []
@@ -92,9 +109,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
channel_type = call.data[ATTR_CHANNEL_TYPE]
entry: AmberConfigEntry = service.async_get_config_entry(
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
coordinator = entry.runtime_data
forecasts = get_forecasts(channel_type, coordinator.data)
return {"forecasts": forecasts}

View File

@@ -25,6 +25,12 @@
"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": {

View File

@@ -491,24 +491,22 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
"role": "user",
"content": "Where are the following coordinates located: "
f"({zone_home.attributes[ATTR_LATITUDE]},"
f" {zone_home.attributes[ATTR_LONGITUDE]})?",
}
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
},
],
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)

View File

@@ -56,15 +56,6 @@ 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",

View File

@@ -20,7 +20,6 @@ from anthropic.types import (
DocumentBlockParam,
ImageBlockParam,
InputJSONDelta,
JSONOutputFormatParam,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
@@ -95,7 +94,6 @@ 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
@@ -699,25 +697,8 @@ class AnthropicBaseLLMEntity(Entity):
)
if structure and structure_name:
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)
structure_name = slugify(structure_name)
if model_args["thinking"]["type"] == "disabled":
if not tools:
# Simplest case: no tools and no extended thinking
# Add a tool and force its use
@@ -737,7 +718,6 @@ 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",
)
@@ -745,24 +725,22 @@ 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.",
)
)
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,
),
)
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
@@ -783,7 +761,7 @@ class AnthropicBaseLLMEntity(Entity):
_transform_stream(
chat_log,
stream,
output_tool=structure_name or None,
output_tool=structure_name if structure else None,
),
)
]

View File

@@ -297,14 +297,14 @@ class S3BackupAgent(BackupAgent):
return self._backup_cache
backups = {}
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")
)
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")
]
for metadata_file in metadata_files:
try:

View File

@@ -16,18 +16,12 @@ CONNECTION_TIMEOUT = 120 # 2 minutes
# Default TIMEOUT_FOR_UPLOAD is 128 seconds, which is too short for large backups
TIMEOUT_FOR_UPLOAD = 43200 # 12 hours
# Reduced retry count for download operations
# Default is 20 retries with exponential backoff, which can hang for 30+ minutes
# when there are persistent connection errors (e.g., SSL failures)
TRY_COUNT_DOWNLOAD = 3
class B2Http(BaseB2Http): # type: ignore[misc]
"""B2Http with extended timeouts for backup operations."""
CONNECTION_TIMEOUT = CONNECTION_TIMEOUT
TIMEOUT_FOR_UPLOAD = TIMEOUT_FOR_UPLOAD
TRY_COUNT_DOWNLOAD = TRY_COUNT_DOWNLOAD
class B2Session(BaseB2Session): # type: ignore[misc]

View File

@@ -40,10 +40,6 @@ CACHE_TTL = 300
# This prevents uploads from hanging indefinitely
UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout)
# Timeout for metadata download operations (in seconds)
# This prevents the backup system from hanging when B2 connections fail
METADATA_DOWNLOAD_TIMEOUT = 60
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Return the suggested filenames for the backup and metadata files."""
@@ -417,21 +413,12 @@ class BackblazeBackupAgent(BackupAgent):
backups = {}
for file_name, file_version in all_files_in_prefix.items():
if file_name.endswith(METADATA_FILE_SUFFIX):
try:
backup = await asyncio.wait_for(
self._hass.async_add_executor_job(
self._process_metadata_file_sync,
file_name,
file_version,
all_files_in_prefix,
),
timeout=METADATA_DOWNLOAD_TIMEOUT,
)
except TimeoutError:
_LOGGER.warning(
"Timeout downloading metadata file %s", file_name
)
continue
backup = await self._hass.async_add_executor_job(
self._process_metadata_file_sync,
file_name,
file_version,
all_files_in_prefix,
)
if backup:
backups[backup.backup_id] = backup
self._backup_list_cache = backups
@@ -455,18 +442,10 @@ class BackblazeBackupAgent(BackupAgent):
if not file or not metadata_file_version:
raise BackupNotFound(f"Backup {backup_id} not found")
try:
metadata_content = await asyncio.wait_for(
self._hass.async_add_executor_job(
self._download_and_parse_metadata_sync,
metadata_file_version,
),
timeout=METADATA_DOWNLOAD_TIMEOUT,
)
except TimeoutError:
raise BackupAgentError(
f"Timeout downloading metadata for backup {backup_id}"
) from None
metadata_content = await self._hass.async_add_executor_job(
self._download_and_parse_metadata_sync,
metadata_file_version,
)
_LOGGER.debug(
"Successfully retrieved metadata for backup ID %s from file %s",
@@ -489,27 +468,16 @@ class BackblazeBackupAgent(BackupAgent):
# Process metadata files sequentially to avoid exhausting executor pool
for file_name, file_version in all_files_in_prefix.items():
if file_name.endswith(METADATA_FILE_SUFFIX):
try:
(
result_backup_file,
result_metadata_file_version,
) = await asyncio.wait_for(
self._hass.async_add_executor_job(
self._process_metadata_file_for_id_sync,
file_name,
file_version,
backup_id,
all_files_in_prefix,
),
timeout=METADATA_DOWNLOAD_TIMEOUT,
)
except TimeoutError:
_LOGGER.warning(
"Timeout downloading metadata file %s while searching for backup %s",
file_name,
backup_id,
)
continue
(
result_backup_file,
result_metadata_file_version,
) = await self._hass.async_add_executor_job(
self._process_metadata_file_for_id_sync,
file_name,
file_version,
backup_id,
all_files_in_prefix,
)
if result_backup_file and result_metadata_file_version:
return result_backup_file, result_metadata_file_version

View File

@@ -8,10 +8,11 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME
@@ -40,10 +41,21 @@ SET_DATE_TIME_SCHEMA = vol.Schema(
async def async_set_panel_date(call: ServiceCall) -> None:
"""Set the date and time on a bosch alarm panel."""
config_entry: BoschAlarmConfigEntry | None
value: dt.datetime = call.data.get(ATTR_DATETIME, dt_util.now())
config_entry: BoschAlarmConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
if not (config_entry := call.hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": entry_id},
)
if config_entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": config_entry.title},
)
panel = config_entry.runtime_data
try:
await panel.set_panel_date(value)

View File

@@ -155,6 +155,12 @@
"incorrect_door_state": {
"message": "Door cannot be manipulated while it is momentarily unlocked."
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"not_loaded": {
"message": "{target} is not loaded."
},
"unknown_error": {
"message": "An unknown error occurred while setting the date and time on \"{target}\"."
}

View File

@@ -1,5 +1,7 @@
"""Actions for Bring! integration."""
from typing import TYPE_CHECKING
from bring_api import (
ActivityType,
BringAuthException,
@@ -11,6 +13,7 @@ import voluptuous as vol
from homeassistant.components.event import ATTR_EVENT_TYPE
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
@@ -43,6 +46,19 @@ SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry:
"""Return config entry or raise if not found or not loaded."""
entry = hass.config_entries.async_get_entry(entry_id)
if TYPE_CHECKING:
assert entry
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
return entry
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Bring! integration."""
@@ -62,9 +78,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
ATTR_ENTITY_ID: call.data[ATTR_ENTITY_ID],
},
)
config_entry: BringConfigEntry = service.async_get_config_entry(
hass, DOMAIN, entity.config_entry_id
)
config_entry = get_config_entry(hass, entity.config_entry_id)
coordinator = config_entry.runtime_data.data

View File

@@ -124,6 +124,10 @@
"entity_not_found": {
"message": "Failed to send reaction for Bring! — Unknown entity {entity_id}"
},
"entry_not_loaded": {
"message": "The account associated with this Bring! list is either not loaded or disabled in Home Assistant."
},
"notify_missing_argument": {
"message": "This action requires field {field}, please enter a valid value for {field}"
},

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Final
from bsblan import BSBLANError, get_hvac_action_category
from bsblan import BSBLANError
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
@@ -13,7 +13,6 @@ from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE
@@ -129,15 +128,6 @@ 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."""

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"requirements": ["python-bsblan==4.2.0"],
"requirements": ["python-bsblan==4.1.0"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -9,11 +9,10 @@ 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
from homeassistant.const import ATTR_TEMPERATURE, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
@@ -25,16 +24,14 @@ from .entity import BSBLanDualCoordinatorEntity
PARALLEL_UPDATES = 1
# 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
# 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
}
HA_TO_BSBLAN_OPERATION_MODE: dict[str, int] = {
v: k for k, v in BSBLAN_TO_HA_OPERATION_MODE.items()
}
OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()}
async def async_setup_entry(
@@ -66,14 +63,13 @@ 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(HA_TO_BSBLAN_OPERATION_MODE.keys())
self._attr_operation_list = list(OPERATION_MODES_REVERSE.keys())
# Set temperature unit
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@@ -114,11 +110,8 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
"""Return current operation."""
if self.coordinator.data.dhw.operating_mode is None:
return None
# 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
current_mode = self.coordinator.data.dhw.operating_mode.desc
return OPERATION_MODES.get(current_mode)
@property
def current_temperature(self) -> float | None:
@@ -151,12 +144,10 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
# Base class validates operation_mode is in operation_list before calling
bsblan_mode = HA_TO_BSBLAN_OPERATION_MODE[operation_mode]
bsblan_mode = OPERATION_MODES_REVERSE.get(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=str(bsblan_mode))
SetHotWaterParam(operating_mode=bsblan_mode)
)
except BSBLANError as err:
raise HomeAssistantError(
@@ -165,11 +156,3 @@ 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)

View File

@@ -144,7 +144,7 @@ class ComelitAlarmEntity(
"""Update state after action."""
self._area.human_status = area_state
self._area.armed = armed
self.async_write_ha_state()
await self.async_update_ha_state()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""

View File

@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.17.2"],
"requirements": ["pydaikin==2.17.1"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@@ -8,7 +8,6 @@
"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."]
}

View File

@@ -1,92 +0,0 @@
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

View File

@@ -10,6 +10,7 @@ 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,
@@ -18,7 +19,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import selector, service
from homeassistant.helpers import selector
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -87,9 +88,28 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp
def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
"""Get the coordinator from the entry."""
entry: EasyEnergyConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY]
entry_id: str = call.data[ATTR_CONFIG_ENTRY]
entry: EasyEnergyConfigEntry | None = call.hass.config_entries.async_get_entry(
entry_id
)
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

View File

@@ -44,8 +44,14 @@
}
},
"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": {

View File

@@ -6,7 +6,6 @@ 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,
@@ -16,12 +15,6 @@ 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."""
@@ -56,14 +49,17 @@ 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()
return await self.async_step_manual(user_input)
if await self.validate_enocean_conf(user_input):
return self.create_enocean_entry(user_input)
errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH}
devices = await self.hass.async_add_executor_job(dongle.detect)
if len(devices) == 0:
return await self.async_step_manual()
return await self.async_step_manual(user_input)
devices.append(self.MANUAL_PATH_VALUE)
return self.async_show_form(
@@ -79,21 +75,26 @@ 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=self.add_suggested_values_to_schema(MANUAL_SCHEMA, user_input),
data_schema=vol.Schema(
{vol.Required(CONF_DEVICE, default=default_value): str}
),
errors=errors,
)

View File

@@ -80,10 +80,7 @@ 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,
max_iterations=1000,
chat_log, task.structure, default_max_tokens=RECOMMENDED_AI_TASK_MAX_TOKENS
)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):

View File

@@ -486,7 +486,6 @@ 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
@@ -603,7 +602,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(max_iterations):
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
chat_response_generator = await chat.send_message_stream(
message=chat_request

View File

@@ -18,8 +18,8 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, UPLOAD_SCOPE
from .coordinator import GooglePhotosConfigEntry
@@ -80,10 +80,15 @@ def _read_file_contents(
async def _async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: GooglePhotosConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID]
config_entry: GooglePhotosConfigEntry | None = (
call.hass.config_entries.async_get_entry(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(

View File

@@ -62,12 +62,18 @@
"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}"
}

View File

@@ -12,6 +12,7 @@ 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,
@@ -20,8 +21,8 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util.json import JsonObjectType
@@ -59,9 +60,9 @@ get_SHEET_SERVICE_SCHEMA = vol.All(
def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
"""Run append in the executor."""
client = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
try:
sheet = client.open_by_key(entry.unique_id)
sheet = service.open_by_key(entry.unique_id)
except RefreshError:
entry.async_start_reauth(call.hass)
raise
@@ -89,9 +90,9 @@ def _get_from_sheet(
call: ServiceCall, entry: GoogleSheetsConfigEntry
) -> JsonObjectType:
"""Run get in the executor."""
client = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) # type: ignore[no-untyped-call]
try:
sheet = client.open_by_key(entry.unique_id)
sheet = service.open_by_key(entry.unique_id)
except RefreshError:
entry.async_start_reauth(call.hass)
raise
@@ -105,18 +106,27 @@ 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 = service.async_get_config_entry(
call.hass, DOMAIN, call.data[DATA_CONFIG_ENTRY]
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
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 = service.async_get_config_entry(
call.hass, DOMAIN, call.data[DATA_CONFIG_ENTRY]
entry: GoogleSheetsConfigEntry | None = call.hass.config_entries.async_get_entry(
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)

View File

@@ -39,15 +39,6 @@
"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}."
},

View File

@@ -30,9 +30,6 @@
"sensor": {
"valve_position": {
"default": "mdi:pipe-valve"
},
"water_level": {
"default": "mdi:water"
}
},
"switch": {

View File

@@ -356,13 +356,6 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
CharacteristicsTypes.WATER_LEVEL: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.WATER_LEVEL,
name="Water level",
translation_key="water_level",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: HomeKitSensorEntityDescription(
key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION,
name="Valve position",

View File

@@ -143,9 +143,6 @@
"leader": "Leader",
"router": "Router"
}
},
"water_level": {
"name": "Water level"
}
}
},

View File

@@ -42,7 +42,6 @@ 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"
@@ -126,8 +125,6 @@ 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)):
@@ -325,23 +322,6 @@ 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."""

View File

@@ -59,16 +59,3 @@ 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", {}))

View File

@@ -3,8 +3,6 @@
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
@@ -29,7 +27,6 @@ from homematicip.device import (
PassageDetector,
PresenceDetectorIndoor,
RoomControlDeviceAnalog,
SmokeDetector,
SwitchMeasuring,
TemperatureDifferenceSensor2,
TemperatureHumiditySensorDisplay,
@@ -46,7 +43,6 @@ from homematicip.device import (
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
@@ -69,70 +65,7 @@ from homeassistant.helpers.typing import StateType
from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP
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
),
),
)
from .helpers import get_channels_from_device
ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position"
ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle"
@@ -356,15 +289,6 @@ 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)
@@ -632,8 +556,16 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
@property
def native_value(self) -> float | None:
"""Return the state."""
value = self._device.vaporAmount
if value is None or value == "":
if self.functional_channel is None:
return None
value = self.functional_channel.vaporAmount
# Handle case where value might be None
if (
self.functional_channel.vaporAmount is None
or self.functional_channel.vaporAmount == ""
):
return None
return round(value, 3)
@@ -1004,33 +936,6 @@ 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:

View File

@@ -29,21 +29,6 @@
},
"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",

View File

@@ -17,7 +17,6 @@ from homematicip.device import (
PlugableSwitch,
PrintedCircuitBoardSwitch2,
PrintedCircuitBoardSwitchBattery,
StatusBoard8,
SwitchMeasuring,
WiredInput32,
WiredInputSwitch6,
@@ -58,7 +57,6 @@ async def async_setup_entry(
WiredSwitch4,
WiredSwitch8,
OpenCollector8Module,
StatusBoard8,
BrandSwitch2,
PrintedCircuitBoardSwitch2,
HeatingSwitch2,

View File

@@ -1,41 +0,0 @@
"""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.SENSOR]
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

View File

@@ -1,119 +0,0 @@
"""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},
)

View File

@@ -1,9 +0,0 @@
"""Constants for the Homevolt integration."""
from __future__ import annotations
from datetime import timedelta
DOMAIN = "homevolt"
MANUFACTURER = "Homevolt"
SCAN_INTERVAL = timedelta(seconds=15)

View File

@@ -1,56 +0,0 @@
"""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

View File

@@ -1,11 +0,0 @@
{
"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"]
}

View File

@@ -1,70 +0,0 @@
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

View File

@@ -1,365 +0,0 @@
"""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

View File

@@ -1,147 +0,0 @@
{
"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": {
"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"
}
}
}
}

View File

@@ -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,11 +38,23 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
service_call.data,
)
hass = service_call.hass
target_entry: ImmichConfigEntry = service.async_get_config_entry(
hass, DOMAIN, service_call.data[CONF_CONFIG_ENTRY_ID]
target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry(
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(

View File

@@ -79,6 +79,12 @@
"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."
},

View File

@@ -1,51 +0,0 @@
"""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)

View File

@@ -1,71 +0,0 @@
"""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,
)

View File

@@ -1,11 +0,0 @@
"""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)

View File

@@ -1,45 +0,0 @@
"""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

View File

@@ -1,74 +0,0 @@
"""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
)

View File

@@ -1,173 +0,0 @@
"""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()

View File

@@ -1,11 +0,0 @@
{
"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"]
}

View File

@@ -1,75 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: 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.

View File

@@ -1,26 +0,0 @@
{
"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"
}
}
}
}

View File

@@ -30,7 +30,7 @@ from .entity import IOmeterEntity
class IOmeterEntityDescription(SensorEntityDescription):
"""Describes IOmeter sensor entity."""
value_fn: Callable[[IOmeterData], str | int | float | None]
value_fn: Callable[[IOmeterData], str | int | float]
SENSOR_TYPES: list[IOmeterEntityDescription] = [
@@ -73,11 +73,7 @@ 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))
if data.status.device.core.battery_level is not None
else None
),
value_fn=lambda data: int(round(data.status.device.core.battery_level)),
),
IOmeterEntityDescription(
key="pin_status",

View File

@@ -17,13 +17,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_e
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.REMOTE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.REMOTE, Platform.SELECT, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool:

View File

@@ -53,14 +53,6 @@
"warming": "mdi:heat-wave"
}
}
},
"switch": {
"eshift": {
"default": "mdi:blur-linear"
},
"low_latency_mode": {
"default": "mdi:gamepad-round-outline"
}
}
}
}

View File

@@ -173,14 +173,6 @@
"warming": "Warming"
}
}
},
"switch": {
"eshift": {
"name": "E-Shift"
},
"low_latency_mode": {
"name": "Low latency mode"
}
}
}
}

View File

@@ -1,82 +0,0 @@
"""Switch platform for the jvc_projector integration."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Final
from jvcprojector import Command, command as cmd
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
@dataclass(frozen=True, kw_only=True)
class JvcProjectorSwitchDescription(SwitchEntityDescription):
"""Describes JVC Projector switch entities."""
command: type[Command]
SWITCHES: Final[tuple[JvcProjectorSwitchDescription, ...]] = (
JvcProjectorSwitchDescription(
key="low_latency_mode",
command=cmd.LowLatencyMode,
entity_registry_enabled_default=False,
),
JvcProjectorSwitchDescription(
key="eshift",
command=cmd.EShift,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: JVCConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JVC Projector switch platform from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
JvcProjectorSwitchEntity(coordinator, description)
for description in SWITCHES
if coordinator.supports(description.command)
)
class JvcProjectorSwitchEntity(JvcProjectorEntity, SwitchEntity):
"""JVC Projector class for switch entities."""
def __init__(
self,
coordinator: JvcProjectorDataUpdateCoordinator,
description: JvcProjectorSwitchDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, description.command)
self.command: type[Command] = description.command
self.entity_description = description
self._attr_translation_key = description.key
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
return self.coordinator.data.get(self.command.name) == STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.device.set(self.command, STATE_ON)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.device.set(self.command, STATE_OFF)

View File

@@ -57,14 +57,6 @@ ROBOT_STATUS_TO_HA = {
"sleep": VacuumActivity.IDLE,
"standby": VacuumActivity.IDLE,
"working": VacuumActivity.CLEANING,
"station": VacuumActivity.CLEANING,
"station_dry": VacuumActivity.CLEANING,
"clean_learning": VacuumActivity.CLEANING,
"station_mop": VacuumActivity.CLEANING,
"water_removal": VacuumActivity.CLEANING,
"water_injection": VacuumActivity.CLEANING,
"clean_select": VacuumActivity.CLEANING,
"clean_select_gozone": VacuumActivity.CLEANING,
"error": VacuumActivity.ERROR,
}
ROBOT_BATT_TO_HA = {
@@ -119,7 +111,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity):
super()._update_status()
# Update state.
self._attr_activity = ROBOT_STATUS_TO_HA.get(self.data.current_state)
self._attr_activity = ROBOT_STATUS_TO_HA[self.data.current_state]
# Update battery.
if (level := self.data.battery) is not None:

View File

@@ -2,16 +2,16 @@
from enum import StrEnum
from functools import partial
from typing import Any
from typing import Any, cast
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,15 +53,30 @@ 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: MastodonConfigEntry = service.async_get_config_entry(
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
client = entry.runtime_data.client
status: str = call.data[ATTR_STATUS]

View File

@@ -72,6 +72,12 @@
"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."
},

View File

@@ -176,16 +176,6 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.DoorLock.Attributes.DoorState,),
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="LockActuatorEnabledSensor",
translation_key="actuator",
entity_category=EntityCategory.DIAGNOSTIC,
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.DoorLock.Attributes.ActuatorEnabled,),
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(

View File

@@ -47,9 +47,6 @@
},
"entity": {
"binary_sensor": {
"actuator": {
"name": "Actuator"
},
"alarm_door": {
"name": "Door alarm"
},

View File

@@ -2,6 +2,7 @@
from dataclasses import asdict
from datetime import date
from typing import cast
from aiomealie import (
MealieConnectionError,
@@ -12,6 +13,7 @@ from aiomealie import (
from awesomeversion import AwesomeVersion
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE
from homeassistant.core import (
HomeAssistant,
@@ -21,7 +23,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_END_DATE,
@@ -108,6 +110,24 @@ SERVICE_SET_MEALPLAN_SCHEMA = vol.Any(
)
def _async_get_entry(call: ServiceCall) -> MealieConfigEntry:
"""Get the Mealie config entry."""
config_entry_id: str = call.data[ATTR_CONFIG_ENTRY_ID]
if not (entry := call.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(MealieConfigEntry, entry)
def _validate_mealplan_type(version: AwesomeVersion, entry_type: str) -> None:
"""Validate mealplan entry type, if prior to 3.7.0."""
@@ -131,9 +151,7 @@ def _validate_mealplan_type(version: AwesomeVersion, entry_type: str) -> None:
async def _async_get_mealplan(call: ServiceCall) -> ServiceResponse:
"""Get the mealplan for a specific range."""
entry: MealieConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = _async_get_entry(call)
start_date = call.data.get(ATTR_START_DATE, date.today())
end_date = call.data.get(ATTR_END_DATE, date.today())
if end_date < start_date:
@@ -154,9 +172,7 @@ async def _async_get_mealplan(call: ServiceCall) -> ServiceResponse:
async def _async_get_recipe(call: ServiceCall) -> ServiceResponse:
"""Get a recipe."""
entry: MealieConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = _async_get_entry(call)
recipe_id = call.data[ATTR_RECIPE_ID]
client = entry.runtime_data.client
try:
@@ -177,9 +193,7 @@ async def _async_get_recipe(call: ServiceCall) -> ServiceResponse:
async def _async_get_recipes(call: ServiceCall) -> ServiceResponse:
"""Get recipes."""
entry: MealieConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = _async_get_entry(call)
search_terms = call.data.get(ATTR_SEARCH_TERMS)
result_limit = call.data.get(ATTR_RESULT_LIMIT, 10)
client = entry.runtime_data.client
@@ -200,9 +214,7 @@ async def _async_get_recipes(call: ServiceCall) -> ServiceResponse:
async def _async_import_recipe(call: ServiceCall) -> ServiceResponse:
"""Import a recipe."""
entry: MealieConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = _async_get_entry(call)
url = call.data[ATTR_URL]
include_tags = call.data.get(ATTR_INCLUDE_TAGS, False)
client = entry.runtime_data.client
@@ -225,9 +237,7 @@ async def _async_import_recipe(call: ServiceCall) -> ServiceResponse:
async def _async_set_random_mealplan(call: ServiceCall) -> ServiceResponse:
"""Set a random mealplan."""
entry: MealieConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = _async_get_entry(call)
mealplan_date = call.data[ATTR_DATE]
entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE])
client = entry.runtime_data.client
@@ -248,9 +258,7 @@ async def _async_set_random_mealplan(call: ServiceCall) -> ServiceResponse:
async def _async_set_mealplan(call: ServiceCall) -> ServiceResponse:
"""Set a mealplan."""
entry: MealieConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
entry = _async_get_entry(call)
mealplan_date = call.data[ATTR_DATE]
entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE])
client = entry.runtime_data.client

View File

@@ -132,6 +132,9 @@
"end_date_before_start_date": {
"message": "End date must be after start date."
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"invalid_mealplan_entry_type": {
"message": "Entry type {mealplan_type} is not valid for this Mealie version."
},
@@ -141,6 +144,9 @@
"no_recipes_found": {
"message": "No recipes found matching your search."
},
"not_loaded": {
"message": "{target} is not loaded."
},
"recipe_not_found": {
"message": "Recipe with ID or slug `{recipe_id}` not found."
},

View File

@@ -13,7 +13,6 @@
"websocket_api"
],
"documentation": "https://www.home-assistant.io/integrations/mobile_app",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["nacl"],
"quality_scale": "internal",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/monarchmoney",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["typedmonarchmoney==0.7.0"]
"requirements": ["typedmonarchmoney==0.4.4"]
}

View File

@@ -6,7 +6,7 @@ import asyncio
from contextlib import suppress
import logging
from aionanoleaf2 import EffectsEvent, Nanoleaf, StateEvent, TouchEvent
from aionanoleaf import EffectsEvent, Nanoleaf, StateEvent, TouchEvent
from homeassistant.const import (
CONF_DEVICE_ID,

View File

@@ -7,7 +7,7 @@ import logging
import os
from typing import Any, Final, cast
from aionanoleaf2 import InvalidToken, Nanoleaf, Unauthorized, Unavailable
from aionanoleaf import InvalidToken, Nanoleaf, Unauthorized, Unavailable
import voluptuous as vol
from homeassistant.config_entries import (

View File

@@ -3,7 +3,7 @@
from datetime import timedelta
import logging
from aionanoleaf2 import InvalidToken, Nanoleaf, Unavailable
from aionanoleaf import InvalidToken, Nanoleaf, Unavailable
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

View File

@@ -1,13 +1,7 @@
{
"domain": "nanoleaf",
"name": "Nanoleaf",
"codeowners": [
"@milanmeu",
"@joostlek",
"@loebi-ch",
"@JaspervRijbroek",
"@jonathanrobichaud4"
],
"codeowners": ["@milanmeu", "@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nanoleaf",
"homekit": {
@@ -15,8 +9,8 @@
},
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aionanoleaf2"],
"requirements": ["aionanoleaf2==1.0.2"],
"loggers": ["aionanoleaf"],
"requirements": ["aionanoleaf==0.2.1"],
"ssdp": [
{
"st": "Nanoleaf_aurora:light"

View File

@@ -20,6 +20,7 @@ from pynordpool import (
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DATE
from homeassistant.core import (
HomeAssistant,
@@ -29,7 +30,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
@@ -64,6 +65,21 @@ SERVICE_GET_PRICE_INDICES_SCHEMA = SERVICE_GET_PRICES_SCHEMA.extend(
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry:
"""Return config entry."""
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
return entry
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Nord Pool integration."""
@@ -72,9 +88,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
call: ServiceCall,
) -> tuple[NordPoolClient, date, str, list[str], int]:
"""Return the parameters for the service."""
entry: NordPoolConfigEntry = service.async_get_config_entry(
hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY]
)
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
client = entry.runtime_data.client
asked_date: date = call.data[ATTR_DATE]

View File

@@ -102,6 +102,12 @@
"could_not_fetch_data": {
"message": "Data could not be retrieved: {error}"
},
"entry_not_found": {
"message": "The Nord Pool integration is not configured in Home Assistant."
},
"entry_not_loaded": {
"message": "The Nord Pool integration is currently not loaded or disabled in Home Assistant."
},
"initial_update_failed": {
"message": "Initial update failed on startup with error {error}"
},

View File

@@ -12,7 +12,6 @@ from .coordinator import NRGkickConfigEntry, NRGkickDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -1,18 +1,7 @@
"""API helpers and Home Assistant exceptions for the NRGkick integration."""
"""Home Assistant exceptions for the NRGkick integration."""
from __future__ import annotations
from collections.abc import Awaitable
import aiohttp
from nrgkick_api import (
NRGkickAPIDisabledError,
NRGkickAuthenticationError,
NRGkickCommandRejectedError,
NRGkickConnectionError,
NRGkickInvalidResponseError,
)
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
@@ -48,38 +37,3 @@ class NRGkickApiClientApiDisabledError(NRGkickApiClientError):
class NRGkickApiClientInvalidResponseError(NRGkickApiClientError):
"""Exception for invalid responses from the device."""
translation_domain = DOMAIN
translation_key = "invalid_response"
async def async_api_call[_T](awaitable: Awaitable[_T]) -> _T:
"""Call the NRGkick API and map common library errors.
This helper is intended for one-off API calls outside the coordinator,
such as command-style calls (switch/number/etc.) and config flow
validation, where errors should surface as user-facing `HomeAssistantError`
exceptions. Regular polling is handled by the coordinator.
"""
try:
return await awaitable
except NRGkickCommandRejectedError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_rejected",
translation_placeholders={"reason": err.reason},
) from err
except NRGkickAuthenticationError as err:
raise NRGkickApiClientAuthenticationError from err
except NRGkickAPIDisabledError as err:
raise NRGkickApiClientApiDisabledError from err
except NRGkickInvalidResponseError as err:
raise NRGkickApiClientInvalidResponseError from err
except NRGkickConnectionError as err:
raise NRGkickApiClientCommunicationError(
translation_placeholders={"error": str(err)}
) from err
except (TimeoutError, aiohttp.ClientError, OSError) as err:
raise NRGkickApiClientCommunicationError(
translation_placeholders={"error": str(err)}
) from err

View File

@@ -5,7 +5,13 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from nrgkick_api import NRGkickAPI
import aiohttp
from nrgkick_api import (
NRGkickAPI,
NRGkickAPIDisabledError,
NRGkickAuthenticationError,
NRGkickConnectionError,
)
import voluptuous as vol
import yarl
@@ -27,7 +33,6 @@ from .api import (
NRGkickApiClientCommunicationError,
NRGkickApiClientError,
NRGkickApiClientInvalidResponseError,
async_api_call,
)
from .const import DOMAIN
@@ -91,8 +96,15 @@ async def validate_input(
session=session,
)
await async_api_call(api.test_connection())
info = await async_api_call(api.get_info(["general"], raw=True))
try:
await api.test_connection()
info = await api.get_info(["general"], raw=True)
except NRGkickAuthenticationError as err:
raise NRGkickApiClientAuthenticationError from err
except NRGkickAPIDisabledError as err:
raise NRGkickApiClientApiDisabledError from err
except (NRGkickConnectionError, TimeoutError, aiohttp.ClientError, OSError) as err:
raise NRGkickApiClientCommunicationError from err
device_name = info.get("general", {}).get("device_name")
if not device_name:

View File

@@ -13,7 +13,6 @@ from nrgkick_api import (
NRGkickAPIDisabledError,
NRGkickAuthenticationError,
NRGkickConnectionError,
NRGkickInvalidResponseError,
)
from homeassistant.config_entries import ConfigEntry
@@ -80,11 +79,6 @@ class NRGkickDataUpdateCoordinator(DataUpdateCoordinator[NRGkickData]):
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except NRGkickInvalidResponseError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_response",
) from error
except (TimeoutError, aiohttp.ClientError, OSError) as error:
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@@ -2,14 +2,12 @@
from __future__ import annotations
from collections.abc import Awaitable
from typing import Any
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .api import async_api_call
from .const import DOMAIN
from .coordinator import NRGkickDataUpdateCoordinator
@@ -57,9 +55,3 @@ class NRGkickEntity(CoordinatorEntity[NRGkickDataUpdateCoordinator]):
device_info_typed["connections"] = connections
self._attr_device_info = device_info_typed
async def _async_call_api[_T](self, awaitable: Awaitable[_T]) -> _T:
"""Call the API, map errors, and refresh coordinator data."""
result = await async_api_call(awaitable)
await self.coordinator.async_refresh()
return result

View File

@@ -286,11 +286,6 @@
"unsupported_charging_mode": "Unsupported charging mode"
}
}
},
"switch": {
"charging_enabled": {
"name": "Charging enabled"
}
}
},
"exceptions": {
@@ -300,9 +295,6 @@
"authentication_error": {
"message": "Authentication failed. Please check your credentials."
},
"command_rejected": {
"message": "The device rejected the request: {reason}"
},
"communication_error": {
"message": "Communication error with NRGkick device: {error}"
},

View File

@@ -1,59 +0,0 @@
"""Switch platform for NRGkick."""
from __future__ import annotations
from typing import Any
from nrgkick_api.const import CONTROL_KEY_CHARGE_PAUSE
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NRGkickConfigEntry, NRGkickData, NRGkickDataUpdateCoordinator
from .entity import NRGkickEntity
PARALLEL_UPDATES = 0
CHARGING_ENABLED_KEY = "charging_enabled"
def _is_charging_enabled(data: NRGkickData) -> bool:
"""Return True if charging is enabled (not paused)."""
return bool(data.control.get(CONTROL_KEY_CHARGE_PAUSE) == 0)
async def async_setup_entry(
_hass: HomeAssistant,
entry: NRGkickConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up NRGkick switches based on a config entry."""
coordinator = entry.runtime_data
async_add_entities([NRGkickChargingEnabledSwitch(coordinator)])
class NRGkickChargingEnabledSwitch(NRGkickEntity, SwitchEntity):
"""Representation of the NRGkick charging enabled switch."""
_attr_translation_key = CHARGING_ENABLED_KEY
def __init__(self, coordinator: NRGkickDataUpdateCoordinator) -> None:
"""Initialize the switch."""
super().__init__(coordinator, CHARGING_ENABLED_KEY)
@property
def is_on(self) -> bool:
"""Return the state of the switch."""
data = self.coordinator.data
assert data is not None
return _is_charging_enabled(data)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on (enable charging)."""
await self._async_call_api(self.coordinator.api.set_charge_pause(False))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off (pause charging)."""
await self._async_call_api(self.coordinator.api.set_charge_pause(True))

View File

@@ -47,35 +47,10 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.util.unit_conversion import (
ApparentPowerConverter,
AreaConverter,
BaseUnitConverter,
BloodGlucoseConcentrationConverter,
CarbonMonoxideConcentrationConverter,
ConductivityConverter,
DataRateConverter,
DistanceConverter,
DurationConverter,
ElectricCurrentConverter,
ElectricPotentialConverter,
EnergyConverter,
EnergyDistanceConverter,
InformationConverter,
MassConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
NitrogenMonoxideConcentrationConverter,
OzoneConcentrationConverter,
PowerConverter,
PressureConverter,
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
VolumeConverter,
VolumeFlowRateConverter,
)
@@ -612,45 +587,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
}
UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = {
NumberDeviceClass.APPARENT_POWER: ApparentPowerConverter,
NumberDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter,
NumberDeviceClass.AREA: AreaConverter,
NumberDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter,
NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter,
NumberDeviceClass.CO: CarbonMonoxideConcentrationConverter,
NumberDeviceClass.CONDUCTIVITY: ConductivityConverter,
NumberDeviceClass.CURRENT: ElectricCurrentConverter,
NumberDeviceClass.DATA_RATE: DataRateConverter,
NumberDeviceClass.DATA_SIZE: InformationConverter,
NumberDeviceClass.DISTANCE: DistanceConverter,
NumberDeviceClass.DURATION: DurationConverter,
NumberDeviceClass.ENERGY: EnergyConverter,
NumberDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter,
NumberDeviceClass.ENERGY_STORAGE: EnergyConverter,
NumberDeviceClass.GAS: VolumeConverter,
NumberDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter,
NumberDeviceClass.NITROGEN_MONOXIDE: NitrogenMonoxideConcentrationConverter,
NumberDeviceClass.OZONE: OzoneConcentrationConverter,
NumberDeviceClass.POWER: PowerConverter,
NumberDeviceClass.POWER_FACTOR: UnitlessRatioConverter,
NumberDeviceClass.PRECIPITATION: DistanceConverter,
NumberDeviceClass.PRECIPITATION_INTENSITY: SpeedConverter,
NumberDeviceClass.PRESSURE: PressureConverter,
NumberDeviceClass.REACTIVE_ENERGY: ReactiveEnergyConverter,
NumberDeviceClass.REACTIVE_POWER: ReactivePowerConverter,
NumberDeviceClass.SULPHUR_DIOXIDE: SulphurDioxideConcentrationConverter,
NumberDeviceClass.SPEED: SpeedConverter,
NumberDeviceClass.TEMPERATURE: TemperatureConverter,
NumberDeviceClass.TEMPERATURE_DELTA: TemperatureDeltaConverter,
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: MassVolumeConcentrationConverter,
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: UnitlessRatioConverter,
NumberDeviceClass.VOLTAGE: ElectricPotentialConverter,
NumberDeviceClass.VOLUME: VolumeConverter,
NumberDeviceClass.VOLUME_STORAGE: VolumeConverter,
NumberDeviceClass.VOLUME_FLOW_RATE: VolumeFlowRateConverter,
NumberDeviceClass.WATER: VolumeConverter,
NumberDeviceClass.WEIGHT: MassConverter,
NumberDeviceClass.WIND_SPEED: SpeedConverter,
}
# We translate units that were using using the legacy coding of μ \u00b5

View File

@@ -5,6 +5,7 @@ from typing import Final
from ohme import OhmeApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -12,7 +13,8 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.helpers import selector, service
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import selector
from .const import DOMAIN
from .coordinator import OhmeConfigEntry
@@ -46,9 +48,25 @@ SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema(
def __get_client(call: ServiceCall) -> OhmeApiClient:
"""Get the client from the config entry."""
entry: OhmeConfigEntry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY]
)
entry_id: str = call.data[ATTR_CONFIG_ENTRY]
entry: OhmeConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id)
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.charge_session_coordinator.client

View File

@@ -121,6 +121,12 @@
},
"device_info_failed": {
"message": "Unable to get Ohme device information"
},
"invalid_config_entry": {
"message": "Invalid config entry provided. Got {config_entry}"
},
"unloaded_config_entry": {
"message": "Invalid config entry provided. {config_entry} is not loaded."
}
},
"services": {

View File

@@ -18,8 +18,8 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import OneDriveConfigEntry
@@ -77,9 +77,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
async def async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: OneDriveConfigEntry = service.async_get_config_entry(
hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID]
config_entry: OneDriveConfigEntry | None = hass.config_entries.async_get_entry(
call.data[CONF_CONFIG_ENTRY_ID]
)
if not config_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
client = config_entry.runtime_data.client
upload_tasks = []
file_results = await hass.async_add_executor_job(

View File

@@ -105,6 +105,9 @@
"filename_does_not_exist": {
"message": "`{filename}` does not exist"
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"no_access_to_path": {
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},

View File

@@ -34,7 +34,6 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
issue_registry as ir,
selector,
)
from homeassistant.helpers.httpx_client import get_async_client
@@ -50,7 +49,6 @@ from .const import (
CONF_TOP_P,
DEFAULT_AI_TASK_NAME,
DEFAULT_NAME,
DEFAULT_TTS_NAME,
DOMAIN,
LOGGER,
RECOMMENDED_AI_TASK_OPTIONS,
@@ -59,14 +57,13 @@ from .const import (
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_OPTIONS,
)
from .entity import async_prepare_files_for_prompt
SERVICE_GENERATE_IMAGE = "generate_image"
SERVICE_GENERATE_CONTENT = "generate_content"
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.TTS)
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
@@ -78,22 +75,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def render_image(call: ServiceCall) -> ServiceResponse:
"""Render an image with dall-e."""
LOGGER.warning(
"Action '%s.%s' is deprecated and will be removed in the 2026.9.0 release. "
"Please use the 'ai_task.generate_image' action instead",
DOMAIN,
SERVICE_GENERATE_IMAGE,
)
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_generate_image",
breaks_in_ha_version="2026.9.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_generate_image",
)
entry_id = call.data["config_entry"]
entry = hass.config_entries.async_get_entry(entry_id)
@@ -129,22 +110,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def send_prompt(call: ServiceCall) -> ServiceResponse:
"""Send a prompt to ChatGPT and return the response."""
LOGGER.warning(
"Action '%s.%s' is deprecated and will be removed in the 2026.9.0 release. "
"Please use the 'ai_task.generate_data' action instead",
DOMAIN,
SERVICE_GENERATE_CONTENT,
)
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_generate_content",
breaks_in_ha_version="2026.9.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_generate_content",
)
entry_id = call.data["config_entry"]
entry = hass.config_entries.async_get_entry(entry_id)
@@ -476,10 +441,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
)
hass.config_entries.async_update_entry(entry, minor_version=4)
if entry.version == 2 and entry.minor_version == 4:
_add_tts_subentry(hass, entry)
hass.config_entries.async_update_entry(entry, minor_version=5)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
@@ -498,16 +459,3 @@ def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None
unique_id=None,
),
)
def _add_tts_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
"""Add TTS subentry to the config entry."""
hass.config_entries.async_add_subentry(
entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
subentry_type="tts",
title=DEFAULT_TTS_NAME,
unique_id=None,
),
)

View File

@@ -39,9 +39,6 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
SelectSelectorMode,
TemplateSelector,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.typing import VolDictType
@@ -56,7 +53,6 @@ from .const import (
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_TTS_SPEED,
CONF_VERBOSITY,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
@@ -68,7 +64,6 @@ from .const import (
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL,
@@ -80,8 +75,6 @@ from .const import (
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_OPTIONS,
RECOMMENDED_TTS_SPEED,
RECOMMENDED_VERBOSITY,
RECOMMENDED_WEB_SEARCH,
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
@@ -117,7 +110,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenAI Conversation."""
VERSION = 2
MINOR_VERSION = 5
MINOR_VERSION = 4
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -158,12 +151,6 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
{
"subentry_type": "tts",
"data": RECOMMENDED_TTS_OPTIONS,
"title": DEFAULT_TTS_NAME,
"unique_id": None,
},
],
)
@@ -204,13 +191,13 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
return {
"conversation": OpenAISubentryFlowHandler,
"ai_task_data": OpenAISubentryFlowHandler,
"tts": OpenAISubentryTTSFlowHandler,
}
class OpenAISubentryFlowHandler(ConfigSubentryFlow):
"""Flow for managing OpenAI subentries."""
last_rendered_recommended = False
options: dict[str, Any]
@property
@@ -593,77 +580,3 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
_LOGGER.debug("Location data: %s", location_data)
return location_data
class OpenAISubentryTTSFlowHandler(ConfigSubentryFlow):
"""Flow for managing OpenAI TTS subentries."""
options: dict[str, Any]
@property
def _is_new(self) -> bool:
"""Return if this is a new subentry."""
return self.source == "user"
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a subentry."""
self.options = RECOMMENDED_TTS_OPTIONS.copy()
return await self.async_step_init()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle reconfiguration of a subentry."""
self.options = self._get_reconfigure_subentry().data.copy()
return await self.async_step_init()
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage initial options."""
# abort if entry is not loaded
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
options = self.options
errors: dict[str, str] = {}
step_schema: VolDictType = {}
if self._is_new:
step_schema[vol.Required(CONF_NAME, default=DEFAULT_TTS_NAME)] = str
step_schema.update(
{
vol.Optional(CONF_PROMPT): TextSelector(
TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)
),
vol.Optional(
CONF_TTS_SPEED, default=RECOMMENDED_TTS_SPEED
): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
}
)
if user_input is not None:
options.update(user_input)
if not errors:
if self._is_new:
return self.async_create_entry(
title=options.pop(CONF_NAME),
data=options,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=options,
)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(step_schema), options
),
errors=errors,
)

View File

@@ -10,7 +10,6 @@ LOGGER: logging.Logger = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
DEFAULT_AI_TASK_NAME = "OpenAI AI Task"
DEFAULT_TTS_NAME = "OpenAI TTS"
DEFAULT_NAME = "OpenAI Conversation"
CONF_CHAT_MODEL = "chat_model"
@@ -24,7 +23,6 @@ CONF_REASONING_SUMMARY = "reasoning_summary"
CONF_RECOMMENDED = "recommended"
CONF_TEMPERATURE = "temperature"
CONF_TOP_P = "top_p"
CONF_TTS_SPEED = "tts_speed"
CONF_VERBOSITY = "verbosity"
CONF_WEB_SEARCH = "web_search"
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
@@ -42,7 +40,6 @@ RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_REASONING_SUMMARY = "auto"
RECOMMENDED_TEMPERATURE = 1.0
RECOMMENDED_TOP_P = 1.0
RECOMMENDED_TTS_SPEED = 1.0
RECOMMENDED_VERBOSITY = "medium"
RECOMMENDED_WEB_SEARCH = False
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium"
@@ -108,7 +105,3 @@ RECOMMENDED_CONVERSATION_OPTIONS = {
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}
RECOMMENDED_TTS_OPTIONS = {
CONF_PROMPT: "",
CONF_CHAT_MODEL: "gpt-4o-mini-tts",
}

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