Compare commits

..

7 Commits

Author SHA1 Message Date
mib1185
d1dc193610 make generator better readable 2025-12-15 23:35:07 +00:00
mib1185
53b81c5bbd improve tests 2025-12-14 11:44:54 +00:00
mib1185
37a61427de fix test name 2025-12-14 11:14:42 +00:00
mib1185
f8fd4b99fe use specific trigger mock 2025-12-14 11:12:30 +00:00
Michael
8785867c16 Merge branch 'dev' into fritzbox/add-support-for-routines 2025-12-14 11:28:05 +01:00
mib1185
b5addcfc81 add tests 2025-12-13 18:37:44 +00:00
mib1185
90e103beb9 add support to activte/deactivate routines/triggers 2025-12-13 11:30:59 +00:00
502 changed files with 2951 additions and 12416 deletions

View File

@@ -51,9 +51,6 @@ rules:
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+

View File

@@ -15,7 +15,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2025.12.0"
BASE_IMAGE_VERSION: "2025.11.3"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:
@@ -70,7 +70,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: translations
path: translations.tar.gz
@@ -169,7 +169,7 @@ jobs:
fi
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations
@@ -482,7 +482,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: translations

View File

@@ -263,7 +263,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: venv
key: &key-pre-commit-venv >-
@@ -304,7 +304,7 @@ jobs:
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-restore actions/cache/restore@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: venv
fail-on-cache-miss: true
@@ -511,7 +511,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-save actions/cache/save@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -534,7 +534,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -864,7 +864,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: pytest_buckets
- &compile-english-translations

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with:
category: "/language:python"

View File

@@ -10,7 +10,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with:
github-token: ${{ github.token }}
issue-inactive-days: "30"

View File

@@ -74,7 +74,7 @@ jobs:
) > .env_file
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: env_file
path: ./.env_file
@@ -119,7 +119,7 @@ jobs:
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: env_file

1
CODEOWNERS generated
View File

@@ -665,7 +665,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64
/tests/components/hikvision/ @mezz64
/homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead

2
Dockerfile generated
View File

@@ -30,7 +30,7 @@ RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.9.17
&& pip3 install uv==0.9.6
WORKDIR /usr/src

View File

@@ -624,16 +624,13 @@ async def async_enable_logging(
if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
def rename_old_file() -> None:
"""Rename old log file in executor."""
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
await hass.async_add_executor_job(rename_old_file)
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
err_log_path = None
else:
err_log_path = default_log_path

View File

@@ -9,9 +9,8 @@ from actron_neo_api import (
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import _LOGGER, DOMAIN
from .const import _LOGGER
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
@@ -30,13 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
try:
systems = await api.get_ac_systems()
await api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAuthError:
_LOGGER.error("Authentication error while setting up Actron Air integration")
raise
except ActronAirAPIError as err:
raise ConfigEntryNotReady from err
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:

View File

@@ -1,12 +1,11 @@
"""Setup config flow for Actron Air integration."""
import asyncio
from collections.abc import Mapping
from typing import Any
from actron_neo_api import ActronAirAPI, ActronAirAuthError
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
@@ -96,16 +95,8 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
# Check if this is a reauth flow
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
@@ -123,21 +114,6 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
del self.login_task
return await self.async_step_user()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reauth_confirm")
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -5,23 +5,16 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAuthError,
ActronAirStatus,
)
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN
from .const import _LOGGER
SCAN_INTERVAL = timedelta(seconds=30)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error"
@@ -36,6 +29,9 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration."""
@@ -63,14 +59,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
async def _async_update_data(self) -> ActronAirStatus:
"""Fetch updates and merge incremental changes into the full state."""
try:
await self.api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
await self.api.update_status()
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.2.0"]
"requirements": ["actron-neo-api==0.1.87"]
}

View File

@@ -36,7 +36,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: todo
# Gold

View File

@@ -2,12 +2,10 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start authentication flow",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
"oauth2_error": "Failed to start OAuth2 flow"
},
"error": {
"oauth2_error": "Failed to start authentication flow. Please try again later."
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
},
"progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
@@ -18,23 +16,14 @@
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"title": "Connection error"
},
"reauth_confirm": {
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
"title": "Authentication expired"
},
"timeout": {
"data": {},
"description": "The authentication process timed out. Please try again.",
"title": "Authentication timeout"
"description": "The authorization process timed out. Please try again.",
"title": "Authorization timeout"
},
"user": {
"title": "Actron Air Authentication"
"title": "Actron Air OAuth2 Authorization"
}
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed, please reauthenticate"
}
}
}

View File

@@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityStateTriggerBase,
Trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
make_conditional_entity_state_trigger,
make_entity_state_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
@@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool
return False
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
@@ -38,7 +38,7 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
) -> type[EntityStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
@@ -52,7 +52,7 @@ def make_entity_state_trigger_required_features(
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_entity_transition_trigger(
"armed": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
@@ -89,12 +89,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.DISARMED
),
"triggered": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
}

View File

@@ -4,9 +4,8 @@
"codeowners": ["@pantherale0"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anglian_water",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyanglianwater"],
"quality_scale": "bronze",
"requirements": ["pyanglianwater==3.1.0"]
"requirements": ["pyanglianwater==3.0.0"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@yuxincs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
"quality_scale": "platinum",

View File

@@ -1,22 +1,16 @@
"""Provides triggers for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN
from .entity import AssistSatelliteState
TRIGGERS: dict[str, type[Trigger]] = {
"idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.LISTENING
),
"processing": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.PROCESSING
),
"responding": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.RESPONDING
),
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
"requirements": ["aioasuswrt==1.5.4", "asusrouter==1.21.3"]
"requirements": ["aioasuswrt==1.5.3", "asusrouter==1.21.3"]
}

View File

@@ -4,8 +4,6 @@
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/autarco",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["autarco==3.2.0"]
}

View File

@@ -125,17 +125,14 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"binary_sensor",
"button",
"climate",
"cover",
"device_tracker",
"fan",
"lawn_mower",
"light",
"media_player",
"switch",
"text",
"update",
"vacuum",
}

View File

@@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
@@ -20,7 +20,7 @@ def get_device_class_or_undefined(
return UNDEFINED
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None

View File

@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["blinkpy"],
"requirements": ["blinkpy==0.25.2"]
"requirements": ["blinkpy==0.25.1"]
}

View File

@@ -17,10 +17,5 @@
"press": {
"service": "mdi:gesture-tap-button"
}
},
"triggers": {
"pressed": {
"trigger": "mdi:gesture-tap-button"
}
}
}

View File

@@ -27,11 +27,5 @@
"name": "Press"
}
},
"title": "Button",
"triggers": {
"pressed": {
"description": "Triggers when a button was pressed",
"name": "Button pressed"
}
}
"title": "Button"
}

View File

@@ -1,42 +0,0 @@
"""Provides triggers for buttons."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain = DOMAIN
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
"pressed": ButtonPressedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for buttons."""
return TRIGGERS

View File

@@ -1,4 +0,0 @@
pressed:
target:
entity:
domain: button

View File

@@ -3,22 +3,22 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
make_conditional_entity_state_trigger,
make_entity_state_attribute_trigger,
make_entity_state_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
TRIGGERS: dict[str, type[Trigger]] = {
"started_cooling": make_entity_target_state_attribute_trigger(
"started_cooling": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_target_state_attribute_trigger(
"started_drying": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
HVACMode.OFF,
@@ -32,7 +32,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
HVACMode.HEAT_COOL,
},
),
"started_heating": make_entity_target_state_attribute_trigger(
"started_heating": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.3.4"]
"requirements": ["compit-inext-api==0.3.1"]
}

View File

@@ -65,10 +65,8 @@ def websocket_create_area(
data.pop("id")
if "aliases" in data:
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
if "labels" in data:
# Convert labels to a set
@@ -135,10 +133,8 @@ def websocket_update_area(
data.pop("id")
if "aliases" in data:
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
if "labels" in data:
# Convert labels to a set

View File

@@ -227,10 +227,8 @@ def websocket_update_entity(
changes[key] = msg[key]
if "aliases" in msg:
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
# Convert aliases to a set
changes["aliases"] = set(msg["aliases"])
if "labels" in msg:
# Convert labels to a set

View File

@@ -61,10 +61,8 @@ def websocket_create_floor(
data.pop("id")
if "aliases" in data:
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
try:
entry = registry.async_create(**data)
@@ -119,10 +117,8 @@ def websocket_update_floor(
data.pop("id")
if "aliases" in data:
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
try:
entry = registry.async_update(**data)

View File

@@ -4,7 +4,6 @@
"codeowners": ["@OnFreund"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pycoolmasternet_async"],
"requirements": ["pycoolmasternet-async==0.2.2"]

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any, Protocol
from typing import TYPE_CHECKING, Any, Protocol
import voluptuous as vol
@@ -11,15 +11,18 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionChecker,
ConditionCheckerType,
ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from homeassistant.helpers.typing import ConfigType
from . import DeviceAutomationType, async_get_device_automation_platform
from .helpers import async_validate_device_automation_config
if TYPE_CHECKING:
from homeassistant.helpers import condition
class DeviceAutomationConditionProtocol(Protocol):
"""Define the format of device_condition modules.
@@ -87,21 +90,15 @@ class DeviceCondition(Condition):
assert config.options is not None
self._config = config.options
async def async_get_checker(self) -> ConditionChecker:
async def async_get_checker(self) -> condition.ConditionCheckerType:
"""Test a device condition."""
platform = await async_get_device_automation_platform(
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
platform_checker = platform.async_condition_from_config(
self._hass, self._config
return trace_condition_function(
platform.async_condition_from_config(self._hass, self._config)
)
def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool:
result = platform_checker(self._hass, variables)
return result is not False
return checker
CONDITIONS: dict[str, type[Condition]] = {
"_device": DeviceCondition,

View File

@@ -11,13 +11,5 @@
"see": {
"service": "mdi:account-eye"
}
},
"triggers": {
"entered_home": {
"trigger": "mdi:account-arrow-left"
},
"left_home": {
"trigger": "mdi:account-arrow-right"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_home": "{entity_name} is home",
@@ -48,15 +44,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
@@ -93,27 +80,5 @@
"name": "See"
}
},
"title": "Device tracker",
"triggers": {
"entered_home": {
"description": "Triggers when one or more device trackers enter home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Entered home"
},
"left_home": {
"description": "Triggers when one or more device trackers leave home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Left home"
}
}
"title": "Device tracker"
}

View File

@@ -1,21 +0,0 @@
"""Provides triggers for device_trackers."""
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_origin_state_trigger,
make_entity_target_state_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for device trackers."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: device_tracker
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
entered_home: *trigger_common
left_home: *trigger_common

View File

@@ -4,7 +4,6 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/directv",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["directv"],
"requirements": ["directv==0.4.0"],

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodns==3.6.1"]
"requirements": ["aiodns==3.6.0"]
}

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodukeenergy==0.3.0"]
}

View File

@@ -1,7 +1,5 @@
"""Event platform for ekey bionyx integration."""
from http import HTTPStatus
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
@@ -54,18 +52,8 @@ class EkeyEvent(EventEntity):
async def async_webhook_handler(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response | None:
try:
payload = await request.json()
except ValueError:
return Response(status=HTTPStatus.BAD_REQUEST)
auth = payload.get("auth")
if auth is None:
return Response(status=HTTPStatus.BAD_REQUEST)
if auth != self._auth:
return Response(status=HTTPStatus.UNAUTHORIZED)
self._async_handle_event()
if (await request.json())["auth"] == self._auth:
self._async_handle_event()
return None
webhook_register(

View File

@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["ekey-bionyxpy==1.0.1"]
"requirements": ["ekey-bionyxpy==1.0.0"]
}

View File

@@ -13,7 +13,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/elkm1",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.13"]

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/elvia",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["elvia==0.1.0"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@borpin", "@alexandrecuer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/emoncms",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["pyemoncms==0.1.3"]
}

View File

@@ -13,7 +13,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/emonitor",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["aioemonitor"],
"requirements": ["aioemonitor==1.0.5"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@bdraco", "@cgarwood", "@catsmanac"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",

View File

@@ -4,7 +4,6 @@
"codeowners": ["@gwww", "@michaeldavie"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.12.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@pszafer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/epson",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["epson_projector"],
"requirements": ["epson-projector==0.6.0"]

View File

@@ -7,7 +7,6 @@
"homekit": {
"models": ["Escea"]
},
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pescea==1.0.12"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/evil_genius_labs",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyevilgenius==2.0.0"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@ntilley905"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/faa_delays",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["faadelays"],
"requirements": ["faadelays==2023.9.1"]

View File

@@ -2,13 +2,13 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Lorenzo-Gasparini"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fing",
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.8"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@cyberjunky"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fireservicerota",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.46"]

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["application_credentials", "http"],
"documentation": "https://www.home-assistant.io/integrations/fitbit",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["fitbit", "fitbit_web_api"],
"requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Sander0542"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fivem",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["fivem-api==0.1.2"]
}

View File

@@ -12,7 +12,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/fjaraskupan",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["bleak", "fjaraskupan"],
"requirements": ["fjaraskupan==2.3.3"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@cnico"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flipr",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["flipr_api"],
"requirements": ["flipr-api==1.6.1"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@dmulcahey"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/flo",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioflo"],
"requirements": ["aioflo==2021.11.0"]

View File

@@ -9,7 +9,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/flume",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyflume"],
"requirements": ["PyFlume==0.6.5"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Foscam-wangzhengyu"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/foscam",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["libpyfoscamcgi"],
"requirements": ["libpyfoscamcgi==0.0.9"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@stefano055415"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/freedompro",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyfreedompro"],
"requirements": ["pyfreedompro==1.1.0"]

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import timedelta
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
from pyfritzhome.devicetypes import FritzhomeTemplate
from pyfritzhome.devicetypes import FritzhomeTemplate, FritzhomeTrigger
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
from homeassistant.config_entries import ConfigEntry
@@ -27,6 +27,7 @@ class FritzboxCoordinatorData:
devices: dict[str, FritzhomeDevice]
templates: dict[str, FritzhomeTemplate]
triggers: dict[str, FritzhomeTrigger]
supported_color_properties: dict[str, tuple[dict, list]]
@@ -37,6 +38,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
configuration_url: str
fritz: Fritzhome
has_templates: bool
has_triggers: bool
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
"""Initialize the Fritzbox Smarthome device coordinator."""
@@ -50,8 +52,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices: set[str] = set()
self.new_templates: set[str] = set()
self.new_triggers: set[str] = set()
self.data = FritzboxCoordinatorData({}, {}, {})
self.data = FritzboxCoordinatorData({}, {}, {}, {})
async def async_setup(self) -> None:
"""Set up the coordinator."""
@@ -74,6 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
)
LOGGER.debug("enable smarthome templates: %s", self.has_templates)
self.has_triggers = await self.hass.async_add_executor_job(
self.fritz.has_triggers
)
LOGGER.debug("enable smarthome triggers: %s", self.has_triggers)
self.configuration_url = self.fritz.get_prefixed_host()
await self.async_config_entry_first_refresh()
@@ -92,7 +100,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
available_main_ains = [
ain
for ain, dev in data.devices.items() | data.templates.items()
for ain, dev in (data.devices | data.templates | data.triggers).items()
if dev.device_and_unit_id[1] is None
]
device_reg = dr.async_get(self.hass)
@@ -112,6 +120,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
except RequestConnectionError as ex:
raise UpdateFailed from ex
except HTTPError:
@@ -123,6 +134,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False)
if self.has_templates:
self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
devices = self.fritz.get_devices()
device_data = {}
@@ -156,12 +169,20 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
for template in templates:
template_data[template.ain] = template
trigger_data = {}
if self.has_triggers:
triggers = self.fritz.get_triggers()
for trigger in triggers:
trigger_data[trigger.ain] = trigger
self.new_devices = device_data.keys() - self.data.devices.keys()
self.new_templates = template_data.keys() - self.data.templates.keys()
self.new_triggers = trigger_data.keys() - self.data.triggers.keys()
return FritzboxCoordinatorData(
devices=device_data,
templates=template_data,
triggers=trigger_data,
supported_color_properties=supported_color_properties,
)
@@ -193,6 +214,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
if (
self.data.devices.keys() - new_data.devices.keys()
or self.data.templates.keys() - new_data.templates.keys()
or self.data.triggers.keys() - new_data.triggers.keys()
):
self.cleanup_removed_devices(new_data)

View File

@@ -4,14 +4,17 @@ from __future__ import annotations
from typing import Any
from pyfritzhome.devicetypes import FritzhomeTrigger
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import FritzboxConfigEntry
from .entity import FritzBoxDeviceEntity
from .entity import FritzBoxDeviceEntity, FritzBoxEntity
# Coordinator handles data updates, so we can allow unlimited parallel updates
PARALLEL_UPDATES = 0
@@ -26,21 +29,27 @@ async def async_setup_entry(
coordinator = entry.runtime_data
@callback
def _add_entities(devices: set[str] | None = None) -> None:
"""Add devices."""
def _add_entities(
devices: set[str] | None = None, triggers: set[str] | None = None
) -> None:
"""Add devices and triggers."""
if devices is None:
devices = coordinator.new_devices
if not devices:
if triggers is None:
triggers = coordinator.new_triggers
if not devices and not triggers:
return
async_add_entities(
entities = [
FritzboxSwitch(coordinator, ain)
for ain in devices
if coordinator.data.devices[ain].has_switch
)
] + [FritzboxTrigger(coordinator, ain) for ain in triggers]
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
_add_entities(set(coordinator.data.devices))
_add_entities(set(coordinator.data.devices), set(coordinator.data.triggers))
class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
@@ -70,3 +79,42 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
translation_domain=DOMAIN,
translation_key="manual_switching_disabled",
)
class FritzboxTrigger(FritzBoxEntity, SwitchEntity):
"""The switch class for FRITZ!SmartHome triggers."""
@property
def data(self) -> FritzhomeTrigger:
"""Return the trigger data entity."""
return self.coordinator.data.triggers[self.ain]
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
return DeviceInfo(
name=self.data.name,
identifiers={(DOMAIN, self.ain)},
configuration_url=self.coordinator.configuration_url,
manufacturer="FRITZ!",
model="SmartHome Routine",
)
@property
def is_on(self) -> bool:
"""Return true if the trigger is active."""
return self.data.active # type: ignore [no-any-return]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Activate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_active, self.ain
)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Deactivate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_inactive, self.ain
)
await self.coordinator.async_refresh()

View File

@@ -4,12 +4,7 @@
"codeowners": ["@wlcrs"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/frontier_silicon",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["afsapi==0.2.7"],
"ssdp": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
}
]
"ssdp": [{ "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" }]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@crevetor"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["ayla-iot-unofficial==1.4.7"]
}

View File

@@ -10,7 +10,6 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/fully_kiosk",
"integration_type": "device",
"iot_class": "local_polling",
"mqtt": ["fully/deviceInfo/+"],
"quality_scale": "bronze",

View File

@@ -17,7 +17,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(
key="timeToScreensaverV2",
translation_key="screensaver_time",
native_max_value=86400,
native_max_value=9999,
native_step=1,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -34,7 +34,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(
key="timeToScreenOffV2",
translation_key="screen_off_time",
native_max_value=86400,
native_max_value=9999,
native_step=1,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,

View File

@@ -4,7 +4,6 @@
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/garages_amsterdam",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["odp-amsterdam==6.1.2"]
}

View File

@@ -12,7 +12,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==1.6.0"]

View File

@@ -77,7 +77,6 @@ DEFAULT_DATA = {
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
CONF_FRAMERATE: 2,
CONF_VERIFY_SSL: True,
CONF_RTSP_TRANSPORT: "tcp",
}
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}

View File

@@ -5,7 +5,6 @@ from typing import Any
import botocore.exceptions
from homelink.auth.srp_auth import SRPAuth
import jwt
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
@@ -39,6 +38,8 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Ask for username and password."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
srp_auth = SRPAuth()
try:
tokens = await self.hass.async_add_executor_job(
@@ -47,17 +48,12 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
user_input[CONF_PASSWORD],
)
except botocore.exceptions.ClientError:
_LOGGER.exception("Error authenticating homelink account")
errors["base"] = "srp_auth_failed"
except Exception:
_LOGGER.exception("An unexpected error occurred")
errors["base"] = "unknown"
else:
access_token = jwt.decode(
tokens["AuthenticationResult"]["AccessToken"],
options={"verify_signature": False},
)
await self.async_set_unique_id(access_token["sub"])
self._abort_if_unique_id_configured()
self.external_data = {"tokens": tokens}
return await self.async_step_creation()

View File

@@ -1,9 +1,10 @@
"""Establish MQTT connection and listen for event data."""
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
import logging
from typing import TypedDict
from homelink.model.device import Device
@@ -13,6 +14,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.ssl import get_default_context
_LOGGER = logging.getLogger(__name__)
type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator]
type EventCallback = Callable[[HomeLinkEventData], None]

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/geocaching",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["geocachingapi==0.3.0"]
}

View File

@@ -54,11 +54,7 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=gios.station_name,
# CONF_NAME is still used, but its value is preserved
# primarily for backward compatibility. This allows older
# versions of the software to read the entry data without
# raising errors.
data={**user_input, CONF_NAME: gios.station_name},
data=user_input,
)
except (ApiError, ClientConnectorError, TimeoutError):
errors["base"] = "cannot_connect"
@@ -83,7 +79,8 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN):
sort=True,
mode=SelectSelectorMode.DROPDOWN,
),
)
),
vol.Optional(CONF_NAME, default=self.hass.config.location_name): str,
}
)

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from aiohttp.client_exceptions import ClientConnectorError
from gios import Gios
@@ -13,12 +12,10 @@ from gios.exceptions import GiosError
from gios.model import GiosSensors
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import API_TIMEOUT, DOMAIN, MANUFACTURER, SCAN_INTERVAL, URL
from .const import API_TIMEOUT, DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -54,21 +51,6 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]):
update_interval=SCAN_INTERVAL,
)
station_id = gios.station_id
if TYPE_CHECKING:
# Station ID is Optional in the library, but here we know it is set for sure
# so we can safely assert it is not None for type checking purposes
# Gios instance is created only with a valid station ID in the async_setup_entry.
assert station_id is not None
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(station_id))},
manufacturer=MANUFACTURER,
name=config_entry.data[CONF_NAME],
configuration_url=URL.format(station_id=station_id),
)
async def _async_update_data(self) -> GiosSensors:
"""Update data via library."""
try:

View File

@@ -15,9 +15,10 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -35,6 +36,8 @@ from .const import (
ATTR_SO2,
ATTRIBUTION,
DOMAIN,
MANUFACTURER,
URL,
)
from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator
@@ -181,6 +184,8 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a GIOS entities from a config_entry."""
name = entry.data[CONF_NAME]
coordinator = entry.runtime_data.coordinator
# Due to the change of the attribute name of one sensor, it is necessary to migrate
# the unique_id to the new name.
@@ -203,7 +208,7 @@ async def async_setup_entry(
for description in SENSOR_TYPES:
if getattr(coordinator.data, description.key) is None:
continue
sensors.append(GiosSensor(coordinator, description))
sensors.append(GiosSensor(name, coordinator, description))
async_add_entities(sensors)
@@ -217,13 +222,19 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity):
def __init__(
self,
name: str,
coordinator: GiosDataUpdateCoordinator,
description: GiosSensorEntityDescription,
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(coordinator.gios.station_id))},
manufacturer=MANUFACTURER,
name=name,
configuration_url=URL.format(station_id=coordinator.gios.station_id),
)
if description.subkey:
self._attr_unique_id = (
f"{coordinator.gios.station_id}-{description.key}-{description.subkey}"

View File

@@ -11,9 +11,11 @@
"step": {
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"station_id": "Measuring station"
},
"data_description": {
"name": "Config entry name, by default, this is the name of your Home Assistant instance.",
"station_id": "The name of the measuring station where the environmental data is collected."
},
"title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)"

View File

@@ -4,7 +4,6 @@
"codeowners": ["@timmo001", "@ludeeus"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/github",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aiogithubapi"],
"requirements": ["aiogithubapi==24.6.0"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@engrbm87"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/glances",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["glances_api"],
"requirements": ["glances-api==0.8.0"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@mletenay", "@starkillerOG"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/goodwe",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["goodwe"],
"requirements": ["goodwe==0.4.8"]

View File

@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.2"]
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.1"]
}

View File

@@ -908,21 +908,12 @@ class StartStopTrait(_Trait):
}
if domain in COVER_VALVE_DOMAINS:
assumed_state_or_set_position = bool(
(
self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
& COVER_VALVE_SET_POSITION_FEATURE[domain]
)
or self.state.attributes.get(ATTR_ASSUMED_STATE)
)
return {
"isRunning": state
in (
COVER_VALVE_STATES[domain]["closing"],
COVER_VALVE_STATES[domain]["opening"],
)
or assumed_state_or_set_position
}
raise NotImplementedError(f"Unsupported domain {domain}")
@@ -984,23 +975,11 @@ class StartStopTrait(_Trait):
"""Execute a StartStop command."""
domain = self.state.domain
if command == COMMAND_START_STOP:
assumed_state_or_set_position = bool(
(
self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
& COVER_VALVE_SET_POSITION_FEATURE[domain]
)
or self.state.attributes.get(ATTR_ASSUMED_STATE)
)
if params["start"] is False:
if (
self.state.state
in (
COVER_VALVE_STATES[domain]["closing"],
COVER_VALVE_STATES[domain]["opening"],
)
or assumed_state_or_set_position
):
if self.state.state in (
COVER_VALVE_STATES[domain]["closing"],
COVER_VALVE_STATES[domain]["opening"],
) or self.state.attributes.get(ATTR_ASSUMED_STATE):
await self.hass.services.async_call(
domain,
SERVICE_STOP_COVER_VALVE[domain],
@@ -1013,14 +992,7 @@ class StartStopTrait(_Trait):
ERR_ALREADY_STOPPED,
f"{FRIENDLY_DOMAIN[domain]} is already stopped",
)
elif (
self.state.state
in (
COVER_VALVE_STATES[domain]["open"],
COVER_VALVE_STATES[domain]["closed"],
)
or assumed_state_or_set_position
):
else:
await self.hass.services.async_call(
domain,
SERVICE_TOGGLE_COVER_VALVE[domain],

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google_photos",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google_photos_library_api"],
"requirements": ["google-photos-library-api==0.12.1"]

View File

@@ -5,7 +5,6 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google_tasks",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-api-python-client==2.71.0"]
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@eifinger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_travel_time",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google", "homeassistant.helpers.location"],
"requirements": ["google-maps-routing==0.6.15"]

View File

@@ -138,7 +138,6 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["govee-ble==0.44.0"]
}

View File

@@ -10,8 +10,6 @@ from requests import RequestException
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
AUTH_API_TOKEN,
@@ -21,25 +19,14 @@ from .const import (
DEFAULT_PLANT_ID,
DEFAULT_URL,
DEPRECATED_URLS,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
from .services import async_register_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Growatt Server component."""
# Register services
await async_register_services(hass)
return True
def get_device_list_classic(
api: growattServer.GrowattApi, config: Mapping[str, str]

View File

@@ -46,8 +46,3 @@ ERROR_INVALID_AUTH = "invalid_auth"
# Config flow abort reasons
ABORT_NO_PLANTS = "no_plants"
# Battery modes for TOU (Time of Use) settings
BATT_MODE_LOAD_FIRST = 0
BATT_MODE_BATTERY_FIRST = 1
BATT_MODE_GRID_FIRST = 2

View File

@@ -12,17 +12,10 @@ import growattServer
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
BATT_MODE_BATTERY_FIRST,
BATT_MODE_GRID_FIRST,
BATT_MODE_LOAD_FIRST,
DEFAULT_URL,
DOMAIN,
)
from .const import DEFAULT_URL, DOMAIN
from .models import GrowattRuntimeData
if TYPE_CHECKING:
@@ -254,134 +247,3 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.previous_values[variable] = return_value
return return_value
async def update_time_segment(
self, segment_id: int, batt_mode: int, start_time, end_time, enabled: bool
) -> None:
"""Update an inverter time segment.
Args:
segment_id: Time segment ID (1-9)
batt_mode: Battery mode (0=load first, 1=battery first, 2=grid first)
start_time: Start time (datetime.time object)
end_time: End time (datetime.time object)
enabled: Whether the segment is enabled
"""
_LOGGER.debug(
"Updating time segment %d for device %s (mode=%d, %s-%s, enabled=%s)",
segment_id,
self.device_id,
batt_mode,
start_time,
end_time,
enabled,
)
if self.api_version != "v1":
raise ServiceValidationError(
"Updating time segments requires token authentication"
)
try:
# Use V1 API for token authentication
# The library's _process_response will raise GrowattV1ApiError if error_code != 0
await self.hass.async_add_executor_job(
self.api.min_write_time_segment,
self.device_id,
segment_id,
batt_mode,
start_time,
end_time,
enabled,
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(f"API error updating time segment: {err}") from err
# Update coordinator's cached data without making an API call (avoids rate limit)
if self.data:
# Update the time segment data in the cache
self.data[f"forcedTimeStart{segment_id}"] = start_time.strftime("%H:%M")
self.data[f"forcedTimeStop{segment_id}"] = end_time.strftime("%H:%M")
self.data[f"time{segment_id}Mode"] = batt_mode
self.data[f"forcedStopSwitch{segment_id}"] = 1 if enabled else 0
# Notify entities of the updated data (no API call)
self.async_set_updated_data(self.data)
async def read_time_segments(self) -> list[dict]:
"""Read time segments from an inverter.
Returns:
List of dictionaries containing segment information
"""
_LOGGER.debug("Reading time segments for device %s", self.device_id)
if self.api_version != "v1":
raise ServiceValidationError(
"Reading time segments requires token authentication"
)
# Ensure we have current data
if not self.data:
_LOGGER.debug("Coordinator data not available, triggering refresh")
await self.async_refresh()
time_segments = []
# Extract time segments from coordinator data
for i in range(1, 10): # Segments 1-9
segment = self._parse_time_segment(i)
time_segments.append(segment)
return time_segments
def _parse_time_segment(self, segment_id: int) -> dict:
"""Parse a single time segment from coordinator data."""
# Get raw time values - these should always be present from the API
start_time_raw = self.data.get(f"forcedTimeStart{segment_id}")
end_time_raw = self.data.get(f"forcedTimeStop{segment_id}")
# Handle 'null' or empty values from API
if start_time_raw in ("null", None, ""):
start_time_raw = "0:0"
if end_time_raw in ("null", None, ""):
end_time_raw = "0:0"
# Format times with leading zeros (HH:MM)
start_time = self._format_time(str(start_time_raw))
end_time = self._format_time(str(end_time_raw))
# Get battery mode
batt_mode_int = int(
self.data.get(f"time{segment_id}Mode", BATT_MODE_LOAD_FIRST)
)
# Map numeric mode to string key (matches update_time_segment input format)
mode_map = {
BATT_MODE_LOAD_FIRST: "load_first",
BATT_MODE_BATTERY_FIRST: "battery_first",
BATT_MODE_GRID_FIRST: "grid_first",
}
batt_mode = mode_map.get(batt_mode_int, "load_first")
# Get enabled status
enabled = bool(int(self.data.get(f"forcedStopSwitch{segment_id}", 0)))
return {
"segment_id": segment_id,
"start_time": start_time,
"end_time": end_time,
"batt_mode": batt_mode,
"enabled": enabled,
}
def _format_time(self, time_raw: str) -> str:
"""Format time string to HH:MM format."""
try:
parts = str(time_raw).split(":")
hour = int(parts[0])
minute = int(parts[1])
except (ValueError, IndexError):
return "00:00"
else:
return f"{hour:02d}:{minute:02d}"

View File

@@ -1,10 +0,0 @@
{
"services": {
"read_time_segments": {
"service": "mdi:clock-outline"
},
"update_time_segment": {
"service": "mdi:clock-edit"
}
}
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@johanzander"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/growatt_server",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"requirements": ["growattServer==1.7.1"]

View File

@@ -1,169 +0,0 @@
"""Service handlers for Growatt Server integration."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from .const import (
BATT_MODE_BATTERY_FIRST,
BATT_MODE_GRID_FIRST,
BATT_MODE_LOAD_FIRST,
DOMAIN,
)
if TYPE_CHECKING:
from .coordinator import GrowattCoordinator
async def async_register_services(hass: HomeAssistant) -> None:
"""Register services for Growatt Server integration."""
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
"""Get all MIN coordinators with V1 API from loaded config entries."""
min_coordinators: dict[str, GrowattCoordinator] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state != ConfigEntryState.LOADED:
continue
# Add MIN coordinators from this entry
for coord in entry.runtime_data.devices.values():
if coord.device_type == "min" and coord.api_version == "v1":
min_coordinators[coord.device_id] = coord
return min_coordinators
def get_coordinator(device_id: str) -> GrowattCoordinator:
"""Get coordinator by device_id.
Args:
device_id: Device registry ID (not serial number)
"""
# Get current coordinators (they may have changed since service registration)
min_coordinators = get_min_coordinators()
if not min_coordinators:
raise ServiceValidationError(
"No MIN devices with token authentication are configured. "
"Services require MIN devices with V1 API access."
)
# Device registry ID provided - map to serial number
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if not device_entry:
raise ServiceValidationError(f"Device '{device_id}' not found")
# Extract serial number from device identifiers
serial_number = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
serial_number = identifier[1]
break
if not serial_number:
raise ServiceValidationError(
f"Device '{device_id}' is not a Growatt device"
)
# Find coordinator by serial number
if serial_number not in min_coordinators:
raise ServiceValidationError(
f"MIN device '{serial_number}' not found or not configured for services"
)
return min_coordinators[serial_number]
async def handle_update_time_segment(call: ServiceCall) -> None:
"""Handle update_time_segment service call."""
segment_id: int = int(call.data["segment_id"])
batt_mode_str: str = call.data["batt_mode"]
start_time_str: str = call.data["start_time"]
end_time_str: str = call.data["end_time"]
enabled: bool = call.data["enabled"]
device_id: str = call.data["device_id"]
# Validate segment_id range
if not 1 <= segment_id <= 9:
raise ServiceValidationError(
f"segment_id must be between 1 and 9, got {segment_id}"
)
# Validate and convert batt_mode string to integer
valid_modes = {
"load_first": BATT_MODE_LOAD_FIRST,
"battery_first": BATT_MODE_BATTERY_FIRST,
"grid_first": BATT_MODE_GRID_FIRST,
}
if batt_mode_str not in valid_modes:
raise ServiceValidationError(
f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'"
)
batt_mode: int = valid_modes[batt_mode_str]
# Convert time strings to datetime.time objects
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
try:
# Take only HH:MM part (ignore seconds if present)
start_parts = start_time_str.split(":")
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"start_time must be in HH:MM or HH:MM:SS format"
) from err
try:
# Take only HH:MM part (ignore seconds if present)
end_parts = end_time_str.split(":")
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"end_time must be in HH:MM or HH:MM:SS format"
) from err
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
await coordinator.update_time_segment(
segment_id,
batt_mode,
start_time,
end_time,
enabled,
)
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
"""Handle read_time_segments service call."""
device_id: str = call.data["device_id"]
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
return {"time_segments": time_segments}
# Register services without schema - services.yaml will provide UI definition
# Schema validation happens in the handler functions
hass.services.async_register(
DOMAIN,
"update_time_segment",
handle_update_time_segment,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
"read_time_segments",
handle_read_time_segments,
supports_response=SupportsResponse.ONLY,
)

View File

@@ -1,50 +0,0 @@
# Service definitions for Growatt Server integration
update_time_segment:
fields:
segment_id:
required: true
example: 1
selector:
number:
min: 1
max: 9
mode: box
batt_mode:
required: true
example: "load_first"
selector:
select:
options:
- "load_first"
- "battery_first"
- "grid_first"
translation_key: batt_mode
start_time:
required: true
example: "08:00"
selector:
time:
end_time:
required: true
example: "12:00"
selector:
time:
enabled:
required: true
example: true
selector:
boolean:
device_id:
required: true
selector:
device:
integration: growatt_server
read_time_segments:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server

View File

@@ -523,56 +523,5 @@
}
}
},
"selector": {
"batt_mode": {
"options": {
"battery_first": "Battery first",
"grid_first": "Grid first",
"load_first": "Load first"
}
}
},
"services": {
"read_time_segments": {
"description": "Read all time segments from a supported inverter.",
"fields": {
"device_id": {
"description": "The Growatt device to perform the action on.",
"name": "Device"
}
},
"name": "Read time segments"
},
"update_time_segment": {
"description": "Update a time segment for supported inverters.",
"fields": {
"batt_mode": {
"description": "Battery operation mode for this time segment.",
"name": "Battery mode"
},
"device_id": {
"description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]"
},
"enabled": {
"description": "Whether this time segment is active.",
"name": "Enabled"
},
"end_time": {
"description": "End time for the segment (HH:MM format).",
"name": "End time"
},
"segment_id": {
"description": "Time segment ID (1-9).",
"name": "Segment ID"
},
"start_time": {
"description": "Start time for the segment (HH:MM format).",
"name": "Start time"
}
},
"name": "Update time segment"
}
},
"title": "Growatt Server"
}

View File

@@ -4,7 +4,6 @@
"codeowners": ["@bestycame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hanna",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hanna-cloud==0.0.7"]

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