Compare commits

..

1 Commits

Author SHA1 Message Date
Robert Resch
b5e1869a90 Use docker image to get go2rtc binary 2025-10-23 11:27:23 +02:00
348 changed files with 1675 additions and 15215 deletions

View File

@@ -33,7 +33,7 @@
"GitHub.vscode-pull-request-github",
"GitHub.copilot"
],
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.jsonc
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
"settings": {
"python.experiments.optOutFrom": ["pythonTestAdapter"],
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
@@ -63,9 +63,6 @@
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],

View File

@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: translations
path: translations.tar.gz
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: translations
@@ -464,7 +464,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: translations

View File

@@ -535,7 +535,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -867,7 +867,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: pytest_buckets
- &compile-english-translations

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
with:
category: "/language:python"

View File

@@ -92,7 +92,7 @@ jobs:
) > build_constraints.txt
- name: Upload env_file
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: env_file
path: ./.env_file
@@ -150,7 +150,7 @@ jobs:
- &download-env-file
name: Download env_file
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: env_file

2
.gitignore vendored
View File

@@ -111,7 +111,6 @@ virtualization/vagrant/config
!.vscode/cSpell.json
!.vscode/extensions.json
!.vscode/tasks.json
!.vscode/settings.default.jsonc
.env
# Windows Explorer
@@ -141,5 +140,4 @@ pytest_buckets.txt
# AI tooling
.claude/settings.local.json
.serena/

View File

@@ -278,7 +278,6 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*

View File

@@ -9,17 +9,13 @@
"pylint.importStrategy": "fromEnvironment",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
},
"[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
// This value differs between working with devcontainer and locally, therefore this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
{
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
// This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
}

6
CODEOWNERS generated
View File

@@ -494,8 +494,6 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes
/homeassistant/components/fing/ @Lorenzo-Gasparini
/tests/components/fing/ @Lorenzo-Gasparini
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky
@@ -743,8 +741,6 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/inkbird/ @bdraco
@@ -1543,8 +1539,6 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @home-assistant/core
/tests/components/sun/ @home-assistant/core
/homeassistant/components/sunricher_dali_center/ @niracler
/tests/components/sunricher_dali_center/ @niracler
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen

15
Dockerfile generated
View File

@@ -15,20 +15,7 @@ ARG QEMU_CPU
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
COPY --from=ghcr.io/alexxit/go2rtc:1.9.11 /usr/local/bin/go2rtc /bin/go2rtc
# Install uv
RUN pip3 install uv==0.9.5

View File

@@ -5,6 +5,9 @@ build_from:
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["airos==0.6.0"]
"requirements": ["airos==0.5.6"]
}

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.2"]
"requirements": ["aioairzone==1.0.1"]
}

View File

@@ -109,12 +109,12 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=config
)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=config
)
self._abort_if_unique_id_configured()
@@ -248,7 +248,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(discovery_info[CONF_MAC])
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
updates={CONF_HOST: discovery_info[CONF_HOST]}
)
self.context.update(

View File

@@ -816,20 +816,13 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return MediaPlayerState.PAUSED
if media_status.player_is_idle:
return MediaPlayerState.IDLE
if self._chromecast is not None and self._chromecast.is_idle:
# If library consider us idle, that is our off state
# it takes HDMI status into account for cast devices.
return MediaPlayerState.OFF
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
# Some apps don't report media status, show the player as playing
return MediaPlayerState.PLAYING
if self.app_id is not None:
# We have an active app
if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID:
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
# Some apps don't report media status, show the player as playing
return MediaPlayerState.PLAYING
return MediaPlayerState.IDLE
if self._chromecast is not None and self._chromecast.is_idle:
return MediaPlayerState.OFF
return None
@property

View File

@@ -14,7 +14,6 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from .const import DEFAULT_PORT, DOMAIN
from .errors import (
ConnectionRefused,
ConnectionReset,
ConnectionTimeout,
ResolveFailed,
ValidationFailure,
@@ -50,8 +49,6 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
self._errors[CONF_HOST] = "connection_timeout"
except ConnectionRefused:
self._errors[CONF_HOST] = "connection_refused"
except ConnectionReset:
self._errors[CONF_HOST] = "connection_reset"
except ValidationFailure:
return True
else:

View File

@@ -25,7 +25,3 @@ class ConnectionTimeout(TemporaryFailure):
class ConnectionRefused(TemporaryFailure):
"""Network connection refused."""
class ConnectionReset(TemporaryFailure):
"""Network connection reset."""

View File

@@ -13,7 +13,6 @@ from homeassistant.util.ssl import get_default_context
from .const import TIMEOUT
from .errors import (
ConnectionRefused,
ConnectionReset,
ConnectionTimeout,
ResolveFailed,
ValidationFailure,
@@ -59,8 +58,6 @@ async def get_cert_expiry_timestamp(
raise ConnectionRefused(
f"Connection refused by server: {hostname}:{port}"
) from err
except ConnectionResetError as err:
raise ConnectionReset(f"Connection reset by server: {hostname}:{port}") from err
except ssl.CertificateError as err:
raise ValidationFailure(err.verify_message) from err
except ssl.SSLError as err:

View File

@@ -14,8 +14,7 @@
"error": {
"resolve_failed": "This host cannot be resolved",
"connection_timeout": "Timeout when connecting to this host",
"connection_refused": "Connection refused when connecting to host",
"connection_reset": "Connection reset when connecting to host"
"connection_refused": "Connection refused when connecting to host"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",

View File

@@ -34,7 +34,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
@dataclass

View File

@@ -1,301 +0,0 @@
"""Platform for Control4 Climate/Thermostat."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyControl4.climate import C4Climate
from pyControl4.error_handling import C4Exception
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
from .const import CONTROL4_ENTITY_TYPE
from .director_utils import update_variables_for_config_entry
from .entity import Control4Entity
_LOGGER = logging.getLogger(__name__)
CONTROL4_CATEGORY = "comfort"
# Control4 variable names
CONTROL4_HVAC_STATE = "HVAC_STATE"
CONTROL4_HVAC_MODE = "HVAC_MODE"
CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F"
CONTROL4_HUMIDITY = "HUMIDITY"
CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F"
CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F"
VARIABLES_OF_INTEREST = {
CONTROL4_HVAC_STATE,
CONTROL4_HVAC_MODE,
CONTROL4_CURRENT_TEMPERATURE,
CONTROL4_HUMIDITY,
CONTROL4_COOL_SETPOINT,
CONTROL4_HEAT_SETPOINT,
}
# Map Control4 HVAC modes to Home Assistant
C4_TO_HA_HVAC_MODE = {
"Off": HVACMode.OFF,
"Cool": HVACMode.COOL,
"Heat": HVACMode.HEAT,
"Auto": HVACMode.HEAT_COOL,
}
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
# Map Control4 HVAC state to Home Assistant HVAC action
C4_TO_HA_HVAC_ACTION = {
"heating": HVACAction.HEATING,
"cooling": HVACAction.COOLING,
"idle": HVACAction.IDLE,
"off": HVACAction.OFF,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: Control4ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Control4 thermostats from a config entry."""
runtime_data = entry.runtime_data
async def async_update_data() -> dict[int, dict[str, Any]]:
"""Fetch data from Control4 director for thermostats."""
try:
return await update_variables_for_config_entry(
hass, entry, VARIABLES_OF_INTEREST
)
except C4Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
_LOGGER,
name="climate",
update_method=async_update_data,
update_interval=timedelta(seconds=runtime_data.scan_interval),
config_entry=entry,
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
entity_list = []
for item in items_of_category:
try:
if item["type"] == CONTROL4_ENTITY_TYPE:
item_name = item["name"]
item_id = item["id"]
item_parent_id = item["parentId"]
item_manufacturer = None
item_device_name = None
item_model = None
for parent_item in items_of_category:
if parent_item["id"] == item_parent_id:
item_manufacturer = parent_item.get("manufacturer")
item_device_name = parent_item.get("roomName")
item_model = parent_item.get("model")
else:
continue
except KeyError:
_LOGGER.exception(
"Unknown device properties received from Control4: %s",
item,
)
continue
# Skip if we don't have data for this thermostat
if item_id not in coordinator.data:
_LOGGER.warning(
"Couldn't get climate state data for %s (ID: %s), skipping setup",
item_name,
item_id,
)
continue
entity_list.append(
Control4Climate(
runtime_data,
coordinator,
item_name,
item_id,
item_device_name,
item_manufacturer,
item_model,
item_parent_id,
)
)
async_add_entities(entity_list)
class Control4Climate(Control4Entity, ClimateEntity):
"""Control4 climate entity."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL]
def __init__(
self,
runtime_data: Control4RuntimeData,
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
name: str,
idx: int,
device_name: str | None,
device_manufacturer: str | None,
device_model: str | None,
device_id: int,
) -> None:
"""Initialize Control4 climate entity."""
super().__init__(
runtime_data,
coordinator,
name,
idx,
device_name,
device_manufacturer,
device_model,
device_id,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._thermostat_data is not None
def _create_api_object(self) -> C4Climate:
"""Create a pyControl4 device object.
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
"""
return C4Climate(self.runtime_data.director, self._idx)
@property
def _thermostat_data(self) -> dict[str, Any] | None:
"""Return the thermostat data from the coordinator."""
return self.coordinator.data.get(self._idx)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
data = self._thermostat_data
if data is None:
return None
return data.get(CONTROL4_CURRENT_TEMPERATURE)
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
data = self._thermostat_data
if data is None:
return None
humidity = data.get(CONTROL4_HUMIDITY)
return int(humidity) if humidity is not None else None
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode."""
data = self._thermostat_data
if data is None:
return HVACMode.OFF
c4_mode = data.get(CONTROL4_HVAC_MODE) or ""
return C4_TO_HA_HVAC_MODE.get(c4_mode, HVACMode.OFF)
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action."""
data = self._thermostat_data
if data is None:
return None
c4_state = data.get(CONTROL4_HVAC_STATE)
if c4_state is None:
return None
# Convert state to lowercase for mapping
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
data = self._thermostat_data
if data is None:
return None
hvac_mode = self.hvac_mode
if hvac_mode == HVACMode.COOL:
return data.get(CONTROL4_COOL_SETPOINT)
if hvac_mode == HVACMode.HEAT:
return data.get(CONTROL4_HEAT_SETPOINT)
return None
@property
def target_temperature_high(self) -> float | None:
"""Return the high target temperature for auto mode."""
data = self._thermostat_data
if data is None:
return None
if self.hvac_mode == HVACMode.HEAT_COOL:
return data.get(CONTROL4_COOL_SETPOINT)
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the low target temperature for auto mode."""
data = self._thermostat_data
if data is None:
return None
if self.hvac_mode == HVACMode.HEAT_COOL:
return data.get(CONTROL4_HEAT_SETPOINT)
return None
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target HVAC mode."""
c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode]
c4_climate = self._create_api_object()
await c4_climate.setHvacMode(c4_hvac_mode)
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
c4_climate = self._create_api_object()
low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
temp = kwargs.get(ATTR_TEMPERATURE)
# Handle temperature range for auto mode
if self.hvac_mode == HVACMode.HEAT_COOL:
if low_temp is not None:
await c4_climate.setHeatSetpointF(low_temp)
if high_temp is not None:
await c4_climate.setCoolSetpointF(high_temp)
# Handle single temperature setpoint
elif temp is not None:
if self.hvac_mode == HVACMode.COOL:
await c4_climate.setCoolSetpointF(temp)
elif self.hvac_mode == HVACMode.HEAT:
await c4_climate.setHeatSetpointF(temp)
await self.coordinator.async_request_refresh()

View File

@@ -184,8 +184,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_API_KEY: self.api_key,
},
reload_on_update=False,
}
)
except TimeoutError:
@@ -232,8 +231,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
},
reload_on_update=False,
}
)
self.context.update(
@@ -267,8 +265,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_API_KEY: self.api_key,
},
reload_on_update=False,
}
)
self.context["configuration_url"] = HASSIO_CONFIGURATION_URL

View File

@@ -122,12 +122,10 @@ class WanIpSensor(SensorEntity):
try:
async with asyncio.timeout(10):
response = await self.resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
except TimeoutError:
await self.resolver.close()
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
await self.resolver.close()
if response:
sorted_ips = sort_ips(

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/droplet",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pydroplet==2.3.4"],
"requirements": ["pydroplet==2.3.3"],
"zeroconf": ["_droplet._tcp.local."]
}

View File

@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
from .const import SUPPORTED_LIFESPANS
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
@@ -62,7 +62,7 @@ STATION_ENTITY_DESCRIPTIONS = tuple(
key=f"station_action_{action.name.lower()}",
translation_key=f"station_action_{action.name.lower()}",
)
for action in SUPPORTED_STATION_ACTIONS
for action in StationAction
)

View File

@@ -23,11 +23,7 @@ SUPPORTED_LIFESPANS = (
LifeSpan.STATION_FILTER,
)
SUPPORTED_STATION_ACTIONS = (
StationAction.CLEAN_BASE,
StationAction.DRY_MOP,
StationAction.EMPTY_DUSTBIN,
)
SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,)
LEGACY_SUPPORTED_LIFESPANS = (
"main_brush",

View File

@@ -36,12 +36,6 @@
"reset_lifespan_round_mop": {
"default": "mdi:broom"
},
"station_action_clean_base": {
"default": "mdi:home"
},
"station_action_dry_mop": {
"default": "mdi:broom"
},
"station_action_empty_dustbin": {
"default": "mdi:delete-restore"
}

View File

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

View File

@@ -70,12 +70,6 @@
"reset_lifespan_side_brush": {
"name": "Reset side brush lifespan"
},
"station_action_clean_base": {
"name": "Clean base"
},
"station_action_dry_mop": {
"name": "Dry mop"
},
"station_action_empty_dustbin": {
"name": "Empty dustbin"
}

View File

@@ -1,42 +0,0 @@
"""The Fing integration."""
from __future__ import annotations
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from .coordinator import FingConfigEntry, FingDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.DEVICE_TRACKER]
async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) -> bool:
"""Set up the Fing component."""
coordinator = FingDataUpdateCoordinator(hass, config_entry)
await coordinator.async_config_entry_first_refresh()
if coordinator.data.network_id is None:
_LOGGER.warning(
"Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest"
)
raise ConfigEntryError(
"The Agent's API version is outdated. Please update the agent to the latest version."
)
config_entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: FingConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)

View File

@@ -1,114 +0,0 @@
"""Config flow file."""
from contextlib import suppress
import logging
from typing import Any
from fing_agent_api import FingAgent
import httpx
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from .const import DOMAIN, UPNP_AVAILABLE
_LOGGER = logging.getLogger(__name__)
class FingConfigFlow(ConfigFlow, domain=DOMAIN):
"""Fing config flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Set up user step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
devices_response = None
agent_info_response = None
self._async_abort_entries_match(
{CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS]}
)
fing_api = FingAgent(
ip=user_input[CONF_IP_ADDRESS],
port=int(user_input[CONF_PORT]),
key=user_input[CONF_API_KEY],
)
try:
devices_response = await fing_api.get_devices()
with suppress(httpx.ConnectError):
# The suppression is needed because the get_agent_info method isn't available for desktop agents
agent_info_response = await fing_api.get_agent_info()
except httpx.NetworkError as _:
errors["base"] = "cannot_connect"
except httpx.TimeoutException as _:
errors["base"] = "timeout_connect"
except httpx.HTTPStatusError as exception:
description_placeholders["message"] = (
f"{exception.response.status_code} - {exception.response.reason_phrase}"
)
if exception.response.status_code == 401:
errors["base"] = "invalid_api_key"
else:
errors["base"] = "http_status_error"
except httpx.InvalidURL as _:
errors["base"] = "url_error"
except (
httpx.HTTPError,
httpx.CookieConflict,
httpx.StreamError,
) as ex:
_LOGGER.error("Unexpected exception: %s", ex)
errors["base"] = "unknown"
else:
if (
devices_response.network_id is not None
and len(devices_response.network_id) > 0
):
agent_name = user_input.get(CONF_IP_ADDRESS)
upnp_available = False
if agent_info_response is not None:
upnp_available = True
agent_name = agent_info_response.agent_id
await self.async_set_unique_id(agent_info_response.agent_id)
self._abort_if_unique_id_configured()
data = {
CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS],
CONF_PORT: user_input[CONF_PORT],
CONF_API_KEY: user_input[CONF_API_KEY],
UPNP_AVAILABLE: upnp_available,
}
return self.async_create_entry(
title=f"Fing Agent {agent_name}",
data=data,
)
return self.async_abort(reason="api_version_error")
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): str,
vol.Required(CONF_PORT, default="49090"): str,
vol.Required(CONF_API_KEY): str,
}
),
user_input,
),
errors=errors,
description_placeholders=description_placeholders,
)

View File

@@ -1,4 +0,0 @@
"""Const for the Fing integration."""
DOMAIN = "fing"
UPNP_AVAILABLE = "upnp_available"

View File

@@ -1,85 +0,0 @@
"""DataUpdateCoordinator for Fing integration."""
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from fing_agent_api import FingAgent
from fing_agent_api.models import AgentInfoResponse, Device
import httpx
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPNP_AVAILABLE
_LOGGER = logging.getLogger(__name__)
type FingConfigEntry = ConfigEntry[FingDataUpdateCoordinator]
@dataclass
class FingDataObject:
"""Fing Data Object."""
network_id: str | None = None
agent_info: AgentInfoResponse | None = None
devices: dict[str, Device] = field(default_factory=dict)
class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
"""Class to manage fetching data from Fing Agent."""
def __init__(self, hass: HomeAssistant, config_entry: FingConfigEntry) -> None:
"""Initialize global Fing updater."""
self._fing = FingAgent(
ip=config_entry.data[CONF_IP_ADDRESS],
port=int(config_entry.data[CONF_PORT]),
key=config_entry.data[CONF_API_KEY],
)
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
update_interval = timedelta(seconds=30)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
config_entry=config_entry,
)
async def _async_update_data(self) -> FingDataObject:
"""Fetch data from Fing Agent."""
device_response = None
agent_info_response = None
try:
device_response = await self._fing.get_devices()
if self._upnp_available:
agent_info_response = await self._fing.get_agent_info()
except httpx.NetworkError as err:
raise UpdateFailed("Failed to connect") from err
except httpx.TimeoutException as err:
raise UpdateFailed("Timeout establishing connection") from err
except httpx.HTTPStatusError as err:
if err.response.status_code == 401:
raise UpdateFailed("Invalid API key") from err
raise UpdateFailed(
f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}"
) from err
except httpx.InvalidURL as err:
raise UpdateFailed("Invalid hostname or IP address") from err
except (
httpx.HTTPError,
httpx.CookieConflict,
httpx.StreamError,
) as err:
raise UpdateFailed("Unexpected error from HTTP request") from err
else:
return FingDataObject(
device_response.network_id,
agent_info_response,
{device.mac: device for device in device_response.devices},
)

View File

@@ -1,127 +0,0 @@
"""Platform for Device tracker integration."""
from fing_agent_api.models import Device
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FingConfigEntry
from .coordinator import FingDataUpdateCoordinator
from .utils import get_icon_from_type
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FingConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator = config_entry.runtime_data
entity_registry = er.async_get(hass)
tracked_devices: set[str] = set()
@callback
def add_entities() -> None:
latest_devices = set(coordinator.data.devices.keys())
devices_to_remove = tracked_devices - set(latest_devices)
devices_to_add = set(latest_devices) - tracked_devices
entities_to_remove = []
for entity_entry in entity_registry.entities.values():
if entity_entry.config_entry_id != config_entry.entry_id:
continue
try:
_, mac = entity_entry.unique_id.rsplit("-", 1)
if mac in devices_to_remove:
entities_to_remove.append(entity_entry.entity_id)
except ValueError:
continue
for entity_id in entities_to_remove:
entity_registry.async_remove(entity_id)
entities_to_add = []
for mac_addr in devices_to_add:
device = coordinator.data.devices[mac_addr]
entities_to_add.append(FingTrackedDevice(coordinator, device))
tracked_devices.clear()
tracked_devices.update(latest_devices)
async_add_entities(entities_to_add)
add_entities()
config_entry.async_on_unload(coordinator.async_add_listener(add_entities))
class FingTrackedDevice(CoordinatorEntity[FingDataUpdateCoordinator], ScannerEntity):
"""Represent a tracked device."""
_attr_has_entity_name = True
def __init__(self, coordinator: FingDataUpdateCoordinator, device: Device) -> None:
"""Set up FingDevice entity."""
super().__init__(coordinator)
self._device = device
agent_id = coordinator.data.network_id
if coordinator.data.agent_info is not None:
agent_id = coordinator.data.agent_info.agent_id
self._attr_mac_address = self._device.mac
self._attr_unique_id = f"{agent_id}-{self._attr_mac_address}"
self._attr_name = self._device.name
self._attr_icon = get_icon_from_type(self._device.type)
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
return self._device.active
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._device.ip[0] if self._device.ip else None
@property
def entity_registry_enabled_default(self) -> bool:
"""Enable entity by default."""
return True
@property
def unique_id(self) -> str | None:
"""Return the unique ID of the entity."""
return self._attr_unique_id
def check_for_updates(self, new_device: Device) -> bool:
"""Return true if the device has updates."""
new_device_ip = new_device.ip[0] if new_device.ip else None
current_device_ip = self._device.ip[0] if self._device.ip else None
return (
current_device_ip != new_device_ip
or self._device.active != new_device.active
or self._device.type != new_device.type
or self._attr_name != new_device.name
or self._attr_icon != get_icon_from_type(new_device.type)
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
updated_device_data = self.coordinator.data.devices.get(self._device.mac)
if updated_device_data is not None and self.check_for_updates(
updated_device_data
):
self._device = updated_device_data
self._attr_name = updated_device_data.name
self._attr_icon = get_icon_from_type(updated_device_data.type)
er.async_get(self.hass).async_update_entity(
entity_id=self.entity_id,
original_name=self._attr_name,
original_icon=self._attr_icon,
)
self.async_write_ha_state()

View File

@@ -1,10 +0,0 @@
{
"domain": "fing",
"name": "Fing",
"codeowners": ["@Lorenzo-Gasparini"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fing",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.0.3"]
}

View File

@@ -1,72 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration has no actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: There are no actions in Fing integration.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Fing integration entities do not use 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: The integration has no actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: The integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: done
entity-category: todo
entity-device-class:
status: exempt
comment: The integration creates only device tracker entities
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: done
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -1,31 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Set up Fing agent",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"ip_address": "IP address of the Fing agent.",
"port": "Port number of the Fing API.",
"api_key": "API key used to authenticate with the Fing API."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"url_error": "[%key:common::config_flow::error::invalid_host%]",
"http_status_error": "HTTP request failed: {message}"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"api_version_error": "Your agent is using an outdated API version. The required 'network_id' parameter is missing. Please update to the latest API version."
}
}
}

View File

@@ -1,85 +0,0 @@
"""Utils functions."""
from enum import Enum
class DeviceType(Enum):
"""Device types enum."""
GENERIC = "mdi:lan-connect"
MOBILE = PHONE = "mdi:cellphone"
TABLET = IPOD = EREADER = "mdi:tablet"
WATCH = WEARABLE = "mdi:watch"
CAR = AUTOMOTIVE = "mdi:car-back"
MEDIA_PLAYER = "mdi:volume-high"
TELEVISION = "mdi:television"
GAME_CONSOLE = "mdi:nintendo-game-boy"
STREAMING_DONGLE = "mdi:cast"
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
DISC_PLAYER = "mdi:disk-player"
REMOTE_CONTROL = "mdi:remote-tv"
RADIO = "mdi:radio"
PHOTO_CAMERA = PHOTOS = "mdi:camera"
MICROPHONE = VOICE_CONTROL = "mdi:microphone"
PROJECTOR = "mdi:projector"
COMPUTER = DESKTOP = "mdi:desktop-tower"
LAPTOP = "mdi:laptop"
PRINTER = "mdi:printer"
SCANNER = "mdi:scanner"
POS = "mdi:printer-pos"
CLOCK = "mdi:clock"
BARCODE = "mdi:barcode"
SURVEILLANCE_CAMERA = BABY_MONITOR = PET_MONITOR = "mdi:cctv"
POE_PLUG = HEALTH_MONITOR = SMART_HOME = SMART_METER = APPLIANCE = SLEEP = (
"mdi:home-automation"
)
SMART_PLUG = "mdi:power-plug"
LIGHT = "mdi:lightbulb"
THERMOSTAT = HEATING = "mdi:home-thermometer"
POWER_SYSTEM = ENERGY = "mdi:lightning-bolt"
SOLAR_PANEL = "mdi:solar-power"
WASHER = "mdi:washing-machine"
FRIDGE = "mdi:fridge"
CLEANER = "mdi:vacuum"
GARAGE = "mdi:garage"
SPRINKLER = "mdi:sprinkler"
BELL = "mdi:doorbell"
KEY_LOCK = "mdi:lock-smart"
CONTROL_PANEL = SMART_CONTROLLER = "mdi:alarm-panel"
SCALE = "mdi:scale-bathroom"
TOY = "mdi:teddy-bear"
ROBOT = "mdi:robot"
WEATHER = "mdi:weather-cloudy"
ALARM = "mdi:alarm-light"
MOTION_DETECTOR = "mdi:motion-sensor"
SMOKE = HUMIDITY = SENSOR = DOMOTZ_BOX = FINGBOX = "mdi:smoke-detector"
ROUTER = MODEM = GATEWAY = FIREWALL = VPN = SMALL_CELL = "mdi:router-network"
WIFI = WIFI_EXTENDER = "mdi:wifi"
NAS_STORAGE = "mdi:nas"
SWITCH = "mdi:switch"
USB = "mdi:usb"
CLOUD = "mdi:cloud"
BATTERY = "mdi:battery"
NETWORK_APPLIANCE = "mdi:network"
VIRTUAL_MACHINE = MAIL_SERVER = FILE_SERVER = PROXY_SERVER = WEB_SERVER = (
DOMAIN_SERVER
) = COMMUNICATION = "mdi:monitor"
SERVER = "mdi:server"
TERMINAL = "mdi:console"
DATABASE = "mdi:database"
RASPBERRY = ARDUINO = "mdi:raspberry-pi"
PROCESSOR = CIRCUIT_CARD = RFID = "mdi:chip"
INDUSTRIAL = "mdi:factory"
MEDICAL = "mdi:medical-bag"
VOIP = CONFERENCING = "mdi:phone-voip"
FITNESS = "mdi:dumbbell"
POOL = "mdi:pool"
SECURITY_SYSTEM = "mdi:security"
def get_icon_from_type(type: str) -> str:
"""Return the right icon based on the type."""
try:
return DeviceType[type].value
except (ValueError, KeyError):
return "mdi:lan-connect"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.8"]
"requirements": ["pyfirefly==0.1.6"]
}

View File

@@ -15,13 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .const import (
CONF_CHAT_MODEL,
CONF_RECOMMENDED,
LOGGER,
RECOMMENDED_AI_TASK_MAX_TOKENS,
RECOMMENDED_IMAGE_MODEL,
)
from .const import CONF_CHAT_MODEL, CONF_RECOMMENDED, LOGGER, RECOMMENDED_IMAGE_MODEL
from .entity import (
ERROR_GETTING_RESPONSE,
GoogleGenerativeAILLMBaseEntity,
@@ -79,9 +73,7 @@ class GoogleGenerativeAITaskEntity(
chat_log: conversation.ChatLog,
) -> 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
)
await self._async_handle_chat_log(chat_log, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
LOGGER.error(

View File

@@ -32,8 +32,6 @@ CONF_TOP_K = "top_k"
RECOMMENDED_TOP_K = 64
CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 3000
# Input 5000, output 19400 = 0.05 USD
RECOMMENDED_AI_TASK_MAX_TOKENS = 19400
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"

View File

@@ -472,7 +472,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
self,
chat_log: conversation.ChatLog,
structure: vol.Schema | None = None,
default_max_tokens: int | None = None,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
@@ -619,9 +618,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
if not chat_log.unresponded_tool_results:
break
def create_generate_content_config(
self, default_max_tokens: int | None = None
) -> GenerateContentConfig:
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, self.default_model)
@@ -635,12 +632,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=options.get(
CONF_MAX_TOKENS,
default_max_tokens
if default_max_tokens is not None
else RECOMMENDED_MAX_TOKENS,
),
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,

View File

@@ -36,7 +36,7 @@ DEFAULT_URL = SERVER_URLS[0]
DOMAIN = "growatt_server"
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
LOGIN_INVALID_AUTH_CODE = "502"

View File

@@ -1,162 +0,0 @@
"""Number platform for Growatt."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from growattServer import GrowattV1ApiError
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = (
1 # Serialize updates as inverter does not handle concurrent requests
)
@dataclass(frozen=True, kw_only=True)
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
"""Describes Growatt number entity."""
write_key: str | None = None # Parameter ID for writing (if different from api_key)
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
# Reading values returns camelCase keys, while writing requires snake_case keys.
MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = (
GrowattNumberEntityDescription(
key="battery_charge_power_limit",
translation_key="battery_charge_power_limit",
api_key="chargePowerCommand", # Key returned by V1 API
write_key="charge_power", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_charge_soc_limit",
translation_key="battery_charge_soc_limit",
api_key="wchargeSOCLowLimit", # Key returned by V1 API
write_key="charge_stop_soc", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_discharge_power_limit",
translation_key="battery_discharge_power_limit",
api_key="disChargePowerCommand", # Key returned by V1 API
write_key="discharge_power", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
GrowattNumberEntityDescription(
key="battery_discharge_soc_limit",
translation_key="battery_discharge_soc_limit",
api_key="wdisChargeSOCLowLimit", # Key returned by V1 API
write_key="discharge_stop_soc", # Key used to write parameter
native_step=1,
native_min_value=0,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GrowattConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Growatt number entities."""
runtime_data = entry.runtime_data
# Add number entities for each MIN device (only supported with V1 API)
async_add_entities(
GrowattNumber(device_coordinator, description)
for device_coordinator in runtime_data.devices.values()
if (
device_coordinator.device_type == "min"
and device_coordinator.api_version == "v1"
)
for description in MIN_NUMBER_TYPES
)
class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
"""Representation of a Growatt number."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: GrowattNumberEntityDescription
def __init__(
self,
coordinator: GrowattCoordinator,
description: GrowattNumberEntityDescription,
) -> None:
"""Initialize the number."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device_id)},
manufacturer="Growatt",
name=coordinator.device_id,
)
@property
def native_value(self) -> int | None:
"""Return the current value of the number."""
value = self.coordinator.data.get(self.entity_description.api_key)
if value is None:
return None
return int(value)
async def async_set_native_value(self, value: float) -> None:
"""Set the value of the number."""
# Use write_key if specified, otherwise fall back to api_key
parameter_id = (
self.entity_description.write_key or self.entity_description.api_key
)
int_value = int(value)
try:
# Use V1 API to write parameter
await self.hass.async_add_executor_job(
self.coordinator.api.min_write_parameter,
self.coordinator.device_id,
parameter_id,
int_value,
)
except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
# If no exception was raised, the write was successful
_LOGGER.debug(
"Set parameter %s to %s",
parameter_id,
value,
)
# Update the value in coordinator data to avoid triggering an immediate
# refresh that would hit the API rate limit (5-minute polling interval)
self.coordinator.data[self.entity_description.api_key] = int_value
self.async_write_ha_state()

View File

@@ -504,20 +504,6 @@
"name": "Maximum power"
}
},
"number": {
"battery_charge_power_limit": {
"name": "Battery charge power limit"
},
"battery_charge_soc_limit": {
"name": "Battery charge SOC limit"
},
"battery_discharge_power_limit": {
"name": "Battery discharge power limit"
},
"battery_discharge_soc_limit": {
"name": "Battery discharge SOC limit"
}
},
"switch": {
"ac_charge": {
"name": "Charge from grid"

View File

@@ -109,8 +109,6 @@ DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
PLACEHOLDER_KEY_ADDON = "addon"
PLACEHOLDER_KEY_ADDON_INFO = "addon_info"
PLACEHOLDER_KEY_ADDON_DOCUMENTATION = "addon_documentation"
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
@@ -122,7 +120,6 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
CORE_CONTAINER = "homeassistant"
SUPERVISOR_CONTAINER = "hassio_supervisor"
@@ -159,7 +156,6 @@ EXTRA_PLACEHOLDERS = {
ISSUE_KEY_ADDON_PWNED: {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
}

View File

@@ -43,7 +43,6 @@ from .const import (
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
@@ -85,7 +84,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED,
}
_LOGGER = logging.getLogger(__name__)

View File

@@ -18,13 +18,10 @@ from . import get_addons_info, get_issues_info
from .const import (
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_ADDON,
PLACEHOLDER_KEY_ADDON_DOCUMENTATION,
PLACEHOLDER_KEY_ADDON_INFO,
PLACEHOLDER_KEY_COMPONENTS,
PLACEHOLDER_KEY_REFERENCE,
)
@@ -198,23 +195,6 @@ class AddonIssueRepairFlow(SupervisorIssueRepairFlow):
return placeholders or None
class DeprecatedAddonIssueRepairFlow(AddonIssueRepairFlow):
"""Handler for deprecated addon issue fixing flows."""
@property
def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps."""
placeholders: dict[str, str] = super().description_placeholders or {}
if self.issue and self.issue.reference:
placeholders[PLACEHOLDER_KEY_ADDON_INFO] = (
f"homeassistant://hassio/addon/{self.issue.reference}/info"
)
placeholders[PLACEHOLDER_KEY_ADDON_DOCUMENTATION] = (
f"homeassistant://hassio/addon/{self.issue.reference}/documentation"
)
return placeholders or None
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -225,8 +205,6 @@ async def async_create_fix_flow(
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
return DockerConfigIssueRepairFlow(hass, issue_id)
if issue and issue.key == ISSUE_KEY_ADDON_DEPRECATED:
return DeprecatedAddonIssueRepairFlow(hass, issue_id)
if issue and issue.key in {
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,

View File

@@ -56,19 +56,6 @@
"title": "Insecure secrets detected in add-on configuration",
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
},
"issue_addon_deprecated_addon": {
"title": "Installed add-on is deprecated",
"fix_flow": {
"step": {
"addon_execute_remove": {
"description": "Add-on {addon} is marked deprecated by the developer. This means it is no longer being maintained and so may break or become a security issue over time.\n\nReview the [readme]({addon_info}) and [documentation]({addon_documentation}) of the add-on to see if the developer provided instructions.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
}
},
"abort": {
"apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details."
}
}
},
"issue_mount_mount_failed": {
"title": "Network storage device failed",
"fix_flow": {

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.83", "babel==2.15.0"]
"requirements": ["holidays==0.82", "babel==2.15.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.3.1"]
"requirements": ["homematicip==2.3.0"]
}

View File

@@ -11,7 +11,6 @@ from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -72,25 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def get_main_device(
hass: HomeAssistant, entry: HomeWizardConfigEntry
) -> dr.DeviceEntry | None:
"""Helper function to get the main device for the config entry."""
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=entry.entry_id
)
if not device_entries:
return None
# Get first device that is not a sub-device, as this is the main device in HomeWizard
# This is relevant for the P1 Meter which may create sub-devices for external utility meters
return next(
(device for device in device_entries if device.via_device_id is None), None
)
async def async_check_v2_support_and_create_issue(
hass: HomeAssistant, entry: HomeWizardConfigEntry
) -> None:
@@ -99,16 +79,6 @@ async def async_check_v2_support_and_create_issue(
if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)):
return
title = entry.title
# Try to get the name from the device registry
# This is to make it clearer which device needs reconfiguration, as the config entry title is kept default most of the time
if main_device := get_main_device(hass, entry):
device_name = main_device.name_by_user or main_device.name
if device_name and entry.title != device_name:
title = f"{entry.title} ({device_name})"
async_create_issue(
hass,
DOMAIN,
@@ -118,7 +88,7 @@ async def async_check_v2_support_and_create_issue(
learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device",
translation_key="migrate_to_v2_api",
translation_placeholders={
"title": title,
"title": entry.title,
},
severity=IssueSeverity.WARNING,
data={"entry_id": entry.entry_id},

View File

@@ -177,7 +177,7 @@
},
"issues": {
"migrate_to_v2_api": {
"title": "Update the authentication method for {title}",
"title": "Update authentication method",
"fix_flow": {
"step": {
"confirm": {

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.6.0"]
"requirements": ["aioautomower==2.5.0"]
}

View File

@@ -445,8 +445,7 @@
"main_area": "Main area",
"secondary_area": "Secondary area",
"home": "Home",
"demo": "Demo",
"poi": "Point of interest"
"demo": "Demo"
}
},
"uptime": {

View File

@@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
@@ -56,12 +55,12 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
@property
def min_temp(self) -> int:
"""Return configured minimal temperature."""
return self.coordinator.data.sauna_config.min_temp or CONFIG_DEFAULT_MIN_TEMP
return self.coordinator.data.sauna_config.min_temp
@property
def max_temp(self) -> int:
"""Return configured maximum temperature."""
return self.coordinator.data.sauna_config.max_temp or CONFIG_DEFAULT_MAX_TEMP
return self.coordinator.data.sauna_config.max_temp
@property
def hvac_mode(self) -> HVACMode:

View File

@@ -9,6 +9,3 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.
CONFIG_STEAMER = 1
CONFIG_LIGHT = 2
CONFIG_STEAMER_AND_LIGHT = 3
CONFIG_DEFAULT_MIN_TEMP = 40
CONFIG_DEFAULT_MAX_TEMP = 110

View File

@@ -1,95 +0,0 @@
"""The iNELS integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from inelsmqtt import InelsMqtt
from inelsmqtt.devices import Device
from inelsmqtt.discovery import InelsDiscovery
from homeassistant.components import mqtt as ha_mqtt
from homeassistant.components.mqtt import (
ReceiveMessage,
async_prepare_subscribe_topics,
async_subscribe_topics,
async_unsubscribe_topics,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import LOGGER, PLATFORMS
type InelsConfigEntry = ConfigEntry[InelsData]
@dataclass
class InelsData:
"""Represents the data structure for INELS runtime data."""
mqtt: InelsMqtt
devices: list[Device]
async def async_setup_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
"""Set up iNELS from a config entry."""
async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None:
"""Publish an MQTT message using the Home Assistant MQTT client."""
await ha_mqtt.async_publish(hass, topic, payload, qos, retain)
async def mqtt_subscribe(
sub_state: dict[str, Any] | None,
topic: str,
callback_func: Callable[[str, str], None],
) -> dict[str, Any]:
"""Subscribe to MQTT topics using the Home Assistant MQTT client."""
@callback
def mqtt_message_received(msg: ReceiveMessage) -> None:
"""Handle iNELS mqtt messages."""
# Payload is always str at runtime since we don't set encoding=None
# HA uses UTF-8 by default
callback_func(msg.topic, msg.payload) # type: ignore[arg-type]
topics = {
"inels_subscribe_topic": {
"topic": topic,
"msg_callback": mqtt_message_received,
}
}
sub_state = async_prepare_subscribe_topics(hass, sub_state, topics)
await async_subscribe_topics(hass, sub_state)
return sub_state
async def mqtt_unsubscribe(sub_state: dict[str, Any]) -> None:
async_unsubscribe_topics(hass, sub_state)
if not await ha_mqtt.async_wait_for_mqtt_client(hass):
LOGGER.error("MQTT integration not available")
raise ConfigEntryNotReady("MQTT integration not available")
inels_mqtt = InelsMqtt(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe)
devices: list[Device] = await InelsDiscovery(inels_mqtt).start()
# If no devices are discovered, continue with the setup
if not devices:
LOGGER.info("No devices discovered")
entry.runtime_data = InelsData(mqtt=inels_mqtt, devices=devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: InelsConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.mqtt.unsubscribe_topics()
entry.runtime_data.mqtt.unsubscribe_listeners()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,73 +0,0 @@
"""Config flow for iNELS."""
from __future__ import annotations
from typing import Any
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from .const import DOMAIN, TITLE
class INelsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle of iNELS config flow."""
VERSION = 1
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by MQTT discovery."""
if self._async_in_progress():
return self.async_abort(reason="already_in_progress")
# Validate the message, abort if it fails.
if not discovery_info.topic.endswith("/gw"):
# Not an iNELS discovery message.
return self.async_abort(reason="invalid_discovery_info")
if not discovery_info.payload:
# Empty payload, unexpected payload.
return self.async_abort(reason="invalid_discovery_info")
return await self.async_step_confirm_from_mqtt()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
try:
if not mqtt.is_connected(self.hass):
return self.async_abort(reason="mqtt_not_connected")
except KeyError:
return self.async_abort(reason="mqtt_not_configured")
return await self.async_step_confirm_from_user()
async def step_confirm(
self, step_id: str, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
if user_input is not None:
await self.async_set_unique_id(DOMAIN)
return self.async_create_entry(title=TITLE, data={})
return self.async_show_form(step_id=step_id)
async def async_step_confirm_from_mqtt(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup from MQTT discovered."""
return await self.step_confirm(
step_id="confirm_from_mqtt", user_input=user_input
)
async def async_step_confirm_from_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup from user add integration."""
return await self.step_confirm(
step_id="confirm_from_user", user_input=user_input
)

View File

@@ -1,14 +0,0 @@
"""Constants for the iNELS integration."""
import logging
from homeassistant.const import Platform
DOMAIN = "inels"
TITLE = "iNELS"
PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
LOGGER = logging.getLogger(__package__)

View File

@@ -1,61 +0,0 @@
"""Base class for iNELS components."""
from __future__ import annotations
from inelsmqtt.devices import Device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class InelsBaseEntity(Entity):
"""Base iNELS entity."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self,
device: Device,
key: str,
index: int,
) -> None:
"""Init base entity."""
self._device = device
self._device_id = device.unique_id
self._attr_unique_id = self._device_id
# The referenced variable to read from
self._key = key
# The index of the variable list to read from. '-1' for no index
self._index = index
info = device.info()
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
manufacturer=info.manufacturer,
model=info.model_number,
name=device.title,
sw_version=info.sw_version,
)
async def async_added_to_hass(self) -> None:
"""Add subscription of the data listener."""
# Register the HA callback
self._device.add_ha_callback(self._key, self._index, self._callback)
# Subscribe to MQTT updates
self._device.mqtt.subscribe_listener(
self._device.state_topic, self._device.unique_id, self._device.callback
)
def _callback(self) -> None:
"""Get data from broker into the HA."""
if hasattr(self, "hass"):
self.schedule_update_ha_state()
@property
def available(self) -> bool:
"""Return if entity is available."""
return self._device.is_available

View File

@@ -1,15 +0,0 @@
{
"entity": {
"switch": {
"bit": {
"default": "mdi:power-socket-eu"
},
"simple_relay": {
"default": "mdi:power-socket-eu"
},
"relay": {
"default": "mdi:power-socket-eu"
}
}
}
}

View File

@@ -1,13 +0,0 @@
{
"domain": "inels",
"name": "iNELS",
"codeowners": ["@epdevlab"],
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/inels",
"iot_class": "local_push",
"mqtt": ["inels/status/#"],
"quality_scale": "bronze",
"requirements": ["elkoep-aio-mqtt==0.1.0b4"],
"single_config_entry": true
}

View File

@@ -1,118 +0,0 @@
rules:
# Bronze
config-flow: done
test-before-configure: done
unique-config-entry: done
config-flow-test-coverage: done
runtime-data: done
test-before-setup:
status: done
comment: >
Raise "Invalid authentication" and "MQTT Broker is offline or
cannot be reached" otherwise, async_setup_entry returns False
appropriate-polling:
status: done
comment: |
Integration uses local_push.
entity-unique-id:
status: done
comment: |
{MAC}_{DEVICE_ID} is used, for example, 0e97f8b7d30_02E8.
has-entity-name:
status: done
comment: >
Almost all devices are multi-functional, which means that all functions
are equally important -> keep the descriptive name (not setting _attr_name to None).
entity-event-setup:
status: done
comment: |
Subscribe in async_added_to_hass & unsubscribe from async_unload_entry.
dependency-transparency: done
action-setup:
status: exempt
comment: |
No custom actions are defined.
common-modules: done
docs-high-level-description: done
docs-installation-instructions:
status: done
comment: |
A link to the wiki is provided.
docs-removal-instructions: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
brands: done
# Silver
config-entry-unloading: done
log-when-unavailable: todo
entity-unavailable:
status: done
comment: |
available property.
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
reauthentication-flow: todo
parallel-updates:
status: todo
comment: |
For all platforms, add a constant PARALLEL_UPDATES = 0.
test-coverage: done
integration-owner: done
docs-installation-parameters:
status: done
comment: |
A link to the wiki is provided.
docs-configuration-parameters:
status: exempt
comment: >
There is the same options flow in the integration as there is in the
configuration.
# Gold
entity-translations: done
entity-device-class: todo
devices: done
entity-category: todo
entity-disabled-by-default: todo
discovery:
status: todo
comment: |
Currently blocked by a hw limitation.
stale-devices:
status: todo
comment: >
Same as discovery. The async_remove_config_entry_device function should be
implemented at a minimum.
diagnostics: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info:
status: todo
comment: |
Same as discovery.
repair-issues: todo
docs-use-cases: todo
docs-supported-devices:
status: todo
comment: >
In regards to this and below doc requirements, I am not sure whether the
wiki link is acceptable.
docs-supported-functions: todo
docs-data-update: todo
docs-known-limitations: todo
docs-troubleshooting: todo
docs-examples: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
The integration is not making any HTTP requests.
strict-typing: todo

View File

@@ -1,30 +0,0 @@
{
"config": {
"step": {
"confirm_from_user": {
"description": "iNELS devices must be connected to the same broker as the Home Assistant MQTT integration client. Continue setup?"
},
"confirm_from_mqtt": {
"description": "Do you want to set up iNELS?"
}
},
"abort": {
"mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.",
"mqtt_not_configured": "Home Assistant MQTT integration not configured.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
}
},
"entity": {
"switch": {
"bit": {
"name": "Bit{addr}"
},
"simple_relay": {
"name": "Simple relay{index}"
},
"relay": {
"name": "Relay{index}"
}
}
}
}

View File

@@ -1,137 +0,0 @@
"""iNELS switch entity."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from inelsmqtt.devices import Device
from inelsmqtt.utils.common import Bit, Relay, SimpleRelay
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import InelsConfigEntry
from .entity import InelsBaseEntity
@dataclass(frozen=True, kw_only=True)
class InelsSwitchEntityDescription(SwitchEntityDescription):
"""Class describing iNELS switch entities."""
get_state_fn: Callable[[Device, int], Bit | SimpleRelay | Relay]
alerts: list[str] | None = None
placeholder_fn: Callable[[Device, int, bool], dict[str, str]]
SWITCH_TYPES = [
InelsSwitchEntityDescription(
key="bit",
translation_key="bit",
get_state_fn=lambda device, index: device.state.bit[index],
placeholder_fn=lambda device, index, indexed: {
"addr": f" {device.state.bit[index].addr}"
},
),
InelsSwitchEntityDescription(
key="simple_relay",
translation_key="simple_relay",
get_state_fn=lambda device, index: device.state.simple_relay[index],
placeholder_fn=lambda device, index, indexed: {
"index": f" {index + 1}" if indexed else ""
},
),
InelsSwitchEntityDescription(
key="relay",
translation_key="relay",
get_state_fn=lambda device, index: device.state.relay[index],
alerts=["overflow"],
placeholder_fn=lambda device, index, indexed: {
"index": f" {index + 1}" if indexed else ""
},
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: InelsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load iNELS switch."""
entities: list[InelsSwitch] = []
for device in entry.runtime_data.devices:
for description in SWITCH_TYPES:
if hasattr(device.state, description.key):
switch_count = len(getattr(device.state, description.key))
entities.extend(
InelsSwitch(
device=device,
description=description,
index=idx,
switch_count=switch_count,
)
for idx in range(switch_count)
)
async_add_entities(entities, False)
class InelsSwitch(InelsBaseEntity, SwitchEntity):
"""The platform class required by Home Assistant."""
entity_description: InelsSwitchEntityDescription
def __init__(
self,
device: Device,
description: InelsSwitchEntityDescription,
index: int = 0,
switch_count: int = 1,
) -> None:
"""Initialize the switch."""
super().__init__(device=device, key=description.key, index=index)
self.entity_description = description
self._switch_count = switch_count
# Include index in unique_id for devices with multiple switches
unique_key = f"{description.key}{index}" if index else description.key
self._attr_unique_id = f"{self._attr_unique_id}_{unique_key}".lower()
# Set translation placeholders
self._attr_translation_placeholders = self.entity_description.placeholder_fn(
self._device, self._index, self._switch_count > 1
)
def _check_alerts(self, current_state: Bit | SimpleRelay | Relay) -> None:
"""Check if there are active alerts and raise ServiceValidationError if found."""
if self.entity_description.alerts and any(
getattr(current_state, alert_key, None)
for alert_key in self.entity_description.alerts
):
raise ServiceValidationError("Cannot operate switch with active alerts")
@property
def is_on(self) -> bool | None:
"""Return if switch is on."""
current_state = self.entity_description.get_state_fn(self._device, self._index)
return current_state.is_on
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the switch to turn off."""
current_state = self.entity_description.get_state_fn(self._device, self._index)
self._check_alerts(current_state)
current_state.is_on = False
await self._device.set_ha_value(self._device.state)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the switch to turn on."""
current_state = self.entity_description.get_state_fn(self._device, self._index)
self._check_alerts(current_state)
current_state.is_on = True
await self._device.set_ha_value(self._device.state)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/israel_rail",
"iot_class": "cloud_polling",
"loggers": ["israelrailapi"],
"requirements": ["israel-rail-api==0.1.4"]
"requirements": ["israel-rail-api==0.1.3"]
}

View File

@@ -12,7 +12,6 @@ from xknx.devices import (
)
from xknx.devices.fan import FanSpeedMode
from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode
from xknx.remote_value.remote_value_setpoint_shift import SetpointShiftMode
from homeassistant import config_entries
from homeassistant.components.climate import (
@@ -35,53 +34,13 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_SYNC_STATE,
CONTROLLER_MODES,
CURRENT_HVAC_ACTIONS,
DOMAIN,
KNX_MODULE_KEY,
ClimateConf,
)
from .entity import (
KnxUiEntity,
KnxUiEntityPlatformController,
KnxYamlEntity,
_KnxEntityBase,
)
from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .knx_module import KNXModule
from .schema import ClimateSchema
from .storage.const import (
CONF_ENTITY,
CONF_GA_ACTIVE,
CONF_GA_CONTROLLER_MODE,
CONF_GA_CONTROLLER_STATUS,
CONF_GA_FAN_SPEED,
CONF_GA_FAN_SWING,
CONF_GA_FAN_SWING_HORIZONTAL,
CONF_GA_HEAT_COOL,
CONF_GA_HUMIDITY_CURRENT,
CONF_GA_ON_OFF,
CONF_GA_OP_MODE_COMFORT,
CONF_GA_OP_MODE_ECO,
CONF_GA_OP_MODE_PROTECTION,
CONF_GA_OP_MODE_STANDBY,
CONF_GA_OPERATION_MODE,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_TEMPERATURE_CURRENT,
CONF_GA_TEMPERATURE_TARGET,
CONF_GA_VALVE,
CONF_IGNORE_AUTO_MODE,
CONF_TARGET_TEMPERATURE,
)
from .storage.entity_store_schema import ConfClimateFanSpeedMode, ConfSetpointShiftMode
from .storage.util import ConfigExtractor
ATTR_COMMAND_VALUE = "command_value"
CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()}
@@ -94,29 +53,11 @@ async def async_setup_entry(
) -> None:
"""Set up climate(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.CLIMATE,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiClimate,
),
)
config: list[ConfigType] = knx_module.config_yaml[Platform.CLIMATE]
entities: list[KnxYamlEntity | KnxUiEntity] = []
if yaml_platform_config := knx_module.config_yaml.get(Platform.CLIMATE):
entities.extend(
KnxYamlClimate(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.CLIMATE):
entities.extend(
KnxUiClimate(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
async_add_entities(
KNXClimate(knx_module, entity_config) for entity_config in config
)
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
@@ -158,8 +99,8 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
group_address_heat_cool_state=config.get(
ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS
),
operation_modes=config.get(ClimateConf.OPERATION_MODES),
controller_modes=config.get(ClimateConf.CONTROLLER_MODES),
operation_modes=config.get(ClimateSchema.CONF_OPERATION_MODES),
controller_modes=config.get(ClimateSchema.CONF_CONTROLLER_MODES),
)
return XknxClimate(
@@ -179,24 +120,24 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
ClimateSchema.CONF_SETPOINT_SHIFT_STATE_ADDRESS
),
setpoint_shift_mode=config.get(ClimateSchema.CONF_SETPOINT_SHIFT_MODE),
setpoint_shift_max=config[ClimateConf.SETPOINT_SHIFT_MAX],
setpoint_shift_min=config[ClimateConf.SETPOINT_SHIFT_MIN],
temperature_step=config[ClimateConf.TEMPERATURE_STEP],
setpoint_shift_max=config[ClimateSchema.CONF_SETPOINT_SHIFT_MAX],
setpoint_shift_min=config[ClimateSchema.CONF_SETPOINT_SHIFT_MIN],
temperature_step=config[ClimateSchema.CONF_TEMPERATURE_STEP],
group_address_on_off=config.get(ClimateSchema.CONF_ON_OFF_ADDRESS),
group_address_on_off_state=config.get(ClimateSchema.CONF_ON_OFF_STATE_ADDRESS),
on_off_invert=config[ClimateConf.ON_OFF_INVERT],
on_off_invert=config[ClimateSchema.CONF_ON_OFF_INVERT],
group_address_active_state=config.get(ClimateSchema.CONF_ACTIVE_STATE_ADDRESS),
group_address_command_value_state=config.get(
ClimateSchema.CONF_COMMAND_VALUE_STATE_ADDRESS
),
min_temp=config.get(ClimateConf.MIN_TEMP),
max_temp=config.get(ClimateConf.MAX_TEMP),
min_temp=config.get(ClimateSchema.CONF_MIN_TEMP),
max_temp=config.get(ClimateSchema.CONF_MAX_TEMP),
mode=climate_mode,
group_address_fan_speed=config.get(ClimateSchema.CONF_FAN_SPEED_ADDRESS),
group_address_fan_speed_state=config.get(
ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS
),
fan_speed_mode=config[ClimateConf.FAN_SPEED_MODE],
fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE],
group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS),
group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS),
group_address_horizontal_swing=config.get(
@@ -211,195 +152,91 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
)
def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClimate:
"""Return a KNX Climate device to be used within XKNX from UI config."""
sync_state = conf.get(CONF_SYNC_STATE)
op_modes: list[str | HVACOperationMode] = list(HVACOperationMode)
if conf.get(CONF_IGNORE_AUTO_MODE):
op_modes.remove(HVACOperationMode.AUTO)
climate_mode = XknxClimateMode(
xknx,
name=f"{name} Mode",
group_address_operation_mode=conf.get_write(CONF_GA_OPERATION_MODE),
group_address_operation_mode_state=conf.get_state_and_passive(
CONF_GA_OPERATION_MODE
),
group_address_operation_mode_comfort=conf.get_write_and_passive(
CONF_GA_OP_MODE_COMFORT
),
group_address_operation_mode_economy=conf.get_write_and_passive(
CONF_GA_OP_MODE_ECO
),
group_address_operation_mode_protection=conf.get_write_and_passive(
CONF_GA_OP_MODE_PROTECTION
),
group_address_operation_mode_standby=conf.get_write_and_passive(
CONF_GA_OP_MODE_STANDBY
),
group_address_controller_status=conf.get_write(CONF_GA_CONTROLLER_STATUS),
group_address_controller_status_state=conf.get_state_and_passive(
CONF_GA_CONTROLLER_STATUS
),
group_address_controller_mode=conf.get_write(CONF_GA_CONTROLLER_MODE),
group_address_controller_mode_state=conf.get_state_and_passive(
CONF_GA_CONTROLLER_MODE
),
group_address_heat_cool=conf.get_write(CONF_GA_HEAT_COOL),
group_address_heat_cool_state=conf.get_state_and_passive(CONF_GA_HEAT_COOL),
sync_state=sync_state,
operation_modes=op_modes,
)
sps_mode = None
if _sps_dpt := conf.get_dpt(CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT):
sps_mode = (
SetpointShiftMode.DPT6010
if _sps_dpt == ConfSetpointShiftMode.COUNT
else SetpointShiftMode.DPT9002
)
_fan_speed_dpt = conf.get_dpt(CONF_GA_FAN_SPEED)
fan_speed_mode = (
FanSpeedMode.STEP
if _fan_speed_dpt == ConfClimateFanSpeedMode.STEPS
else FanSpeedMode.PERCENT
)
return XknxClimate(
xknx,
name=name,
group_address_temperature=conf.get_state_and_passive(
CONF_GA_TEMPERATURE_CURRENT
),
group_address_target_temperature=conf.get_write(
CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET
),
group_address_target_temperature_state=conf.get_state_and_passive(
CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET
),
group_address_setpoint_shift=conf.get_write(
CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT
),
group_address_setpoint_shift_state=conf.get_state_and_passive(
CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT
),
setpoint_shift_mode=sps_mode,
setpoint_shift_max=conf.get(
CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MAX, default=6
),
setpoint_shift_min=conf.get(
CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MIN, default=-6
),
temperature_step=conf.get(
CONF_TARGET_TEMPERATURE, ClimateConf.TEMPERATURE_STEP, default=0.1
),
group_address_on_off=conf.get_write(CONF_GA_ON_OFF),
group_address_on_off_state=conf.get_state_and_passive(CONF_GA_ON_OFF),
on_off_invert=conf.get(ClimateConf.ON_OFF_INVERT, default=False),
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
sync_state=sync_state,
min_temp=conf.get(ClimateConf.MIN_TEMP),
max_temp=conf.get(ClimateConf.MAX_TEMP),
mode=climate_mode,
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
fan_speed_mode=fan_speed_mode,
group_address_humidity_state=conf.get_state_and_passive(
CONF_GA_HUMIDITY_CURRENT
),
group_address_swing=conf.get_write(CONF_GA_FAN_SWING),
group_address_swing_state=conf.get_state_and_passive(CONF_GA_FAN_SWING),
group_address_horizontal_swing=conf.get_write(CONF_GA_FAN_SWING_HORIZONTAL),
group_address_horizontal_swing_state=conf.get_state_and_passive(
CONF_GA_FAN_SWING_HORIZONTAL
),
)
class _KnxClimate(ClimateEntity, _KnxEntityBase):
class KNXClimate(KnxYamlEntity, ClimateEntity):
"""Representation of a KNX climate device."""
_device: XknxClimate
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "knx_climate"
default_hvac_mode: HVACMode
_last_hvac_mode: HVACMode
fan_zero_mode: str
_fan_modes_percentages: list[int]
def _init_from_device_config(
self,
device: XknxClimate,
default_hvac_mode: HVACMode,
fan_max_step: int,
fan_zero_mode: str,
) -> None:
"""Set attributes that depend on device config."""
self.default_hvac_mode = default_hvac_mode
# non-OFF HVAC mode to be used when turning on the device without on_off address
self._last_hvac_mode = self.default_hvac_mode
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
super().__init__(
knx_module=knx_module,
device=_create_climate(knx_module.xknx, config),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
if device.supports_on_off:
if self._device.supports_on_off:
self._attr_supported_features |= (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
if (
device.mode is not None
and len(device.mode.controller_modes) >= 2
and HVACControllerMode.OFF in device.mode.controller_modes
self._device.mode is not None
and len(self._device.mode.controller_modes) >= 2
and HVACControllerMode.OFF in self._device.mode.controller_modes
):
self._attr_supported_features |= (
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
if (
device.mode is not None
and device.mode.operation_modes # empty list when not writable
self._device.mode is not None
and self._device.mode.operation_modes # empty list when not writable
):
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
self._attr_preset_modes = [
mode.name.lower() for mode in device.mode.operation_modes
mode.name.lower() for mode in self._device.mode.operation_modes
]
self.fan_zero_mode = fan_zero_mode
fan_max_step = config[ClimateSchema.CONF_FAN_MAX_STEP]
self._fan_modes_percentages = [
int(100 * i / fan_max_step) for i in range(fan_max_step + 1)
]
if device.fan_speed is not None and device.fan_speed.initialized:
self.fan_zero_mode: str = config[ClimateSchema.CONF_FAN_ZERO_MODE]
if self._device.fan_speed is not None and self._device.fan_speed.initialized:
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
if fan_max_step == 3:
self._attr_fan_modes = [
fan_zero_mode,
self.fan_zero_mode,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
]
elif fan_max_step == 2:
self._attr_fan_modes = [fan_zero_mode, FAN_LOW, FAN_HIGH]
self._attr_fan_modes = [self.fan_zero_mode, FAN_LOW, FAN_HIGH]
elif fan_max_step == 1:
self._attr_fan_modes = [fan_zero_mode, FAN_ON]
elif device.fan_speed_mode == FanSpeedMode.STEP:
self._attr_fan_modes = [fan_zero_mode] + [
self._attr_fan_modes = [self.fan_zero_mode, FAN_ON]
elif self._device.fan_speed_mode == FanSpeedMode.STEP:
self._attr_fan_modes = [self.fan_zero_mode] + [
str(i) for i in range(1, fan_max_step + 1)
]
else:
self._attr_fan_modes = [fan_zero_mode] + [
self._attr_fan_modes = [self.fan_zero_mode] + [
f"{percentage}%" for percentage in self._fan_modes_percentages[1:]
]
if device.swing.initialized:
if self._device.swing.initialized:
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
self._attr_swing_modes = [SWING_ON, SWING_OFF]
if device.horizontal_swing.initialized:
if self._device.horizontal_swing.initialized:
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF]
self._attr_target_temperature_step = device.temperature_step
self._attr_target_temperature_step = self._device.temperature_step
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
)
self.default_hvac_mode: HVACMode = config[
ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE
]
# non-OFF HVAC mode to be used when turning on the device without on_off address
self._last_hvac_mode: HVACMode = self.default_hvac_mode
@property
def current_temperature(self) -> float | None:
@@ -638,63 +475,3 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase):
if hvac_mode is not HVACMode.OFF:
self._last_hvac_mode = hvac_mode
super().after_update_callback(device)
class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
"""Representation of a KNX climate device configured from YAML."""
_device: XknxClimate
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
super().__init__(
knx_module=knx_module,
device=_create_climate(knx_module.xknx, config),
)
default_hvac_mode: HVACMode = config[ClimateConf.DEFAULT_CONTROLLER_MODE]
fan_max_step = config[ClimateConf.FAN_MAX_STEP]
fan_zero_mode: str = config[ClimateConf.FAN_ZERO_MODE]
self._init_from_device_config(
device=self._device,
default_hvac_mode=default_hvac_mode,
fan_max_step=fan_max_step,
fan_zero_mode=fan_zero_mode,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
)
class KnxUiClimate(_KnxClimate, KnxUiEntity):
"""Representation of a KNX climate device configured from the UI."""
_device: XknxClimate
def __init__(
self, knx_module: KNXModule, unique_id: str, config: ConfigType
) -> None:
"""Initialize of a KNX climate device."""
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = _create_climate_ui(
knx_module.xknx, knx_conf, config[CONF_ENTITY][CONF_NAME]
)
default_hvac_mode = HVACMode(knx_conf.get(ClimateConf.DEFAULT_CONTROLLER_MODE))
fan_max_step = knx_conf.get(ClimateConf.FAN_MAX_STEP)
fan_zero_mode = knx_conf.get(ClimateConf.FAN_ZERO_MODE)
self._init_from_device_config(
device=self._device,
default_hvac_mode=default_hvac_mode,
fan_max_step=fan_max_step,
fan_zero_mode=fan_zero_mode,
)

View File

@@ -160,7 +160,6 @@ SUPPORTED_PLATFORMS_YAML: Final = {
SUPPORTED_PLATFORMS_UI: Final = {
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.SWITCH,
@@ -194,23 +193,3 @@ class CoverConf:
INVERT_UPDOWN: Final = "invert_updown"
INVERT_POSITION: Final = "invert_position"
INVERT_ANGLE: Final = "invert_angle"
class ClimateConf:
"""Common config keys for climate."""
MIN_TEMP: Final = "min_temp"
MAX_TEMP: Final = "max_temp"
TEMPERATURE_STEP: Final = "temperature_step"
SETPOINT_SHIFT_MAX: Final = "setpoint_shift_max"
SETPOINT_SHIFT_MIN: Final = "setpoint_shift_min"
ON_OFF_INVERT: Final = "on_off_invert"
OPERATION_MODES: Final = "operation_modes"
CONTROLLER_MODES: Final = "controller_modes"
DEFAULT_CONTROLLER_MODE: Final = "default_controller_mode"
FAN_MAX_STEP: Final = "fan_max_step"
FAN_SPEED_MODE: Final = "fan_speed_mode"
FAN_ZERO_MODE: Final = "fan_zero_mode"

View File

@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.10.0",
"xknxproject==3.8.2",
"knx-frontend==2025.10.26.81530"
"knx-frontend==2025.10.17.202411"
],
"single_config_entry": true
}

View File

@@ -56,7 +56,6 @@ from .const import (
CONF_STATE_ADDRESS,
CONF_SYNC_STATE,
KNX_ADDRESS,
ClimateConf,
ColorTempModes,
CoverConf,
FanZeroMode,
@@ -307,7 +306,10 @@ class ClimateSchema(KNXPlatformSchema):
CONF_SETPOINT_SHIFT_ADDRESS = "setpoint_shift_address"
CONF_SETPOINT_SHIFT_STATE_ADDRESS = "setpoint_shift_state_address"
CONF_SETPOINT_SHIFT_MODE = "setpoint_shift_mode"
CONF_SETPOINT_SHIFT_MAX = "setpoint_shift_max"
CONF_SETPOINT_SHIFT_MIN = "setpoint_shift_min"
CONF_TEMPERATURE_ADDRESS = "temperature_address"
CONF_TEMPERATURE_STEP = "temperature_step"
CONF_TARGET_TEMPERATURE_ADDRESS = "target_temperature_address"
CONF_TARGET_TEMPERATURE_STATE_ADDRESS = "target_temperature_state_address"
CONF_OPERATION_MODE_ADDRESS = "operation_mode_address"
@@ -325,10 +327,19 @@ class ClimateSchema(KNXPlatformSchema):
CONF_OPERATION_MODE_NIGHT_ADDRESS = "operation_mode_night_address"
CONF_OPERATION_MODE_COMFORT_ADDRESS = "operation_mode_comfort_address"
CONF_OPERATION_MODE_STANDBY_ADDRESS = "operation_mode_standby_address"
CONF_OPERATION_MODES = "operation_modes"
CONF_CONTROLLER_MODES = "controller_modes"
CONF_DEFAULT_CONTROLLER_MODE = "default_controller_mode"
CONF_ON_OFF_ADDRESS = "on_off_address"
CONF_ON_OFF_STATE_ADDRESS = "on_off_state_address"
CONF_ON_OFF_INVERT = "on_off_invert"
CONF_MIN_TEMP = "min_temp"
CONF_MAX_TEMP = "max_temp"
CONF_FAN_SPEED_ADDRESS = "fan_speed_address"
CONF_FAN_SPEED_STATE_ADDRESS = "fan_speed_state_address"
CONF_FAN_MAX_STEP = "fan_max_step"
CONF_FAN_SPEED_MODE = "fan_speed_mode"
CONF_FAN_ZERO_MODE = "fan_zero_mode"
CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address"
CONF_SWING_ADDRESS = "swing_address"
CONF_SWING_STATE_ADDRESS = "swing_state_address"
@@ -348,13 +359,13 @@ class ClimateSchema(KNXPlatformSchema):
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(
ClimateConf.SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
): vol.All(int, vol.Range(min=0, max=32)),
vol.Optional(
ClimateConf.SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN
CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN
): vol.All(int, vol.Range(min=-32, max=0)),
vol.Optional(
ClimateConf.TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP
CONF_TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP
): vol.All(float, vol.Range(min=0, max=2)),
vol.Required(CONF_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): ga_list_validator,
@@ -397,29 +408,29 @@ class ClimateSchema(KNXPlatformSchema):
vol.Optional(CONF_ON_OFF_ADDRESS): ga_list_validator,
vol.Optional(CONF_ON_OFF_STATE_ADDRESS): ga_list_validator,
vol.Optional(
ClimateConf.ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
): cv.boolean,
vol.Optional(ClimateConf.OPERATION_MODES): vol.All(
vol.Optional(CONF_OPERATION_MODES): vol.All(
cv.ensure_list,
[backwards_compatible_xknx_climate_enum_member(HVACOperationMode)],
),
vol.Optional(ClimateConf.CONTROLLER_MODES): vol.All(
vol.Optional(CONF_CONTROLLER_MODES): vol.All(
cv.ensure_list,
[backwards_compatible_xknx_climate_enum_member(HVACControllerMode)],
),
vol.Optional(
ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
CONF_DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
): vol.Coerce(HVACMode),
vol.Optional(ClimateConf.MIN_TEMP): vol.Coerce(float),
vol.Optional(ClimateConf.MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator,
vol.Optional(ClimateConf.FAN_MAX_STEP, default=3): cv.byte,
vol.Optional(CONF_FAN_MAX_STEP, default=3): cv.byte,
vol.Optional(
ClimateConf.FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE
CONF_FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE
): vol.All(vol.Upper, cv.enum(FanSpeedMode)),
vol.Optional(ClimateConf.FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce(
FanZeroMode
),
vol.Optional(CONF_SWING_ADDRESS): ga_list_validator,

View File

@@ -39,10 +39,6 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
_DESCRIPTION_PLACEHOLDERS = {
"sensor_value_types_url": "https://www.home-assistant.io/integrations/knx/#value-types"
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
@@ -52,7 +48,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_SEND,
service_send_to_knx_bus,
schema=SERVICE_KNX_SEND_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
hass.services.async_register(
@@ -68,7 +63,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EVENT_REGISTER,
service_event_register_modify,
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(
@@ -77,7 +71,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_KNX_EXPOSURE_REGISTER,
service_exposure_register_modify,
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
)
async_register_admin_service(

View File

@@ -14,28 +14,6 @@ CONF_DPT: Final = "dpt"
CONF_GA_SENSOR: Final = "ga_sensor"
CONF_GA_SWITCH: Final = "ga_switch"
# Climate
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
CONF_TARGET_TEMPERATURE: Final = "target_temperature"
CONF_GA_TEMPERATURE_TARGET: Final = "ga_temperature_target"
CONF_GA_SETPOINT_SHIFT: Final = "ga_setpoint_shift"
CONF_GA_ACTIVE: Final = "ga_active"
CONF_GA_VALVE: Final = "ga_valve"
CONF_GA_OPERATION_MODE: Final = "ga_operation_mode"
CONF_IGNORE_AUTO_MODE: Final = "ignore_auto_mode"
CONF_GA_OP_MODE_COMFORT: Final = "ga_operation_mode_comfort"
CONF_GA_OP_MODE_ECO: Final = "ga_operation_mode_economy"
CONF_GA_OP_MODE_STANDBY: Final = "ga_operation_mode_standby"
CONF_GA_OP_MODE_PROTECTION: Final = "ga_operation_mode_protection"
CONF_GA_HEAT_COOL: Final = "ga_heat_cool"
CONF_GA_ON_OFF: Final = "ga_on_off"
CONF_GA_CONTROLLER_MODE: Final = "ga_controller_mode"
CONF_GA_CONTROLLER_STATUS: Final = "ga_controller_status"
CONF_GA_FAN_SPEED: Final = "ga_fan_speed"
CONF_GA_FAN_SWING: Final = "ga_fan_swing"
CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal"
# Cover
CONF_GA_UP_DOWN: Final = "ga_up_down"
CONF_GA_STOP: Final = "ga_stop"

View File

@@ -4,7 +4,6 @@ from enum import StrEnum, unique
import voluptuous as vol
from homeassistant.components.climate import HVACMode
from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_ENTITY_ID,
@@ -25,10 +24,8 @@ from ..const import (
CONF_SYNC_STATE,
DOMAIN,
SUPPORTED_PLATFORMS_UI,
ClimateConf,
ColorTempModes,
CoverConf,
FanZeroMode,
)
from .const import (
CONF_COLOR,
@@ -37,47 +34,27 @@ from .const import (
CONF_DATA,
CONF_DEVICE_INFO,
CONF_ENTITY,
CONF_GA_ACTIVE,
CONF_GA_ANGLE,
CONF_GA_BLUE_BRIGHTNESS,
CONF_GA_BLUE_SWITCH,
CONF_GA_BRIGHTNESS,
CONF_GA_COLOR,
CONF_GA_COLOR_TEMP,
CONF_GA_CONTROLLER_MODE,
CONF_GA_CONTROLLER_STATUS,
CONF_GA_FAN_SPEED,
CONF_GA_FAN_SWING,
CONF_GA_FAN_SWING_HORIZONTAL,
CONF_GA_GREEN_BRIGHTNESS,
CONF_GA_GREEN_SWITCH,
CONF_GA_HEAT_COOL,
CONF_GA_HUE,
CONF_GA_HUMIDITY_CURRENT,
CONF_GA_ON_OFF,
CONF_GA_OP_MODE_COMFORT,
CONF_GA_OP_MODE_ECO,
CONF_GA_OP_MODE_PROTECTION,
CONF_GA_OP_MODE_STANDBY,
CONF_GA_OPERATION_MODE,
CONF_GA_POSITION_SET,
CONF_GA_POSITION_STATE,
CONF_GA_RED_BRIGHTNESS,
CONF_GA_RED_SWITCH,
CONF_GA_SATURATION,
CONF_GA_SENSOR,
CONF_GA_SETPOINT_SHIFT,
CONF_GA_STEP,
CONF_GA_STOP,
CONF_GA_SWITCH,
CONF_GA_TEMPERATURE_CURRENT,
CONF_GA_TEMPERATURE_TARGET,
CONF_GA_UP_DOWN,
CONF_GA_VALVE,
CONF_GA_WHITE_BRIGHTNESS,
CONF_GA_WHITE_SWITCH,
CONF_IGNORE_AUTO_MODE,
CONF_TARGET_TEMPERATURE,
)
from .knx_selector import (
AllSerializeFirst,
@@ -132,9 +109,7 @@ BINARY_SENSOR_KNX_SCHEMA = vol.Schema(
min=0, max=600, step=0.1, unit_of_measurement="s"
)
),
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(
allow_false=True
),
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(),
},
)
@@ -336,151 +311,8 @@ SWITCH_KNX_SCHEMA = vol.Schema(
},
)
@unique
class ConfSetpointShiftMode(StrEnum):
"""Enum for setpoint shift mode."""
COUNT = "6.010"
FLOAT = "9.002"
@unique
class ConfClimateFanSpeedMode(StrEnum):
"""Enum for climate fan speed mode."""
PERCENTAGE = "5.001"
STEPS = "5.010"
CLIMATE_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_GA_TEMPERATURE_CURRENT): GASelector(
write=False, state_required=True, valid_dpt="9.001"
),
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
write=False, valid_dpt="9.002"
),
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
GroupSelectOption(
translation_key="group_direct_temp",
schema={
vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector(
write_required=True, valid_dpt="9.001"
),
vol.Required(
ClimateConf.MIN_TEMP, default=7
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-20, max=80, step=1, unit_of_measurement="°C"
)
),
vol.Required(
ClimateConf.MAX_TEMP, default=28
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=100, step=1, unit_of_measurement="°C"
)
),
vol.Required(
ClimateConf.TEMPERATURE_STEP, default=0.1
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1, max=2, step=0.1, unit_of_measurement="K"
),
),
},
),
GroupSelectOption(
translation_key="group_setpoint_shift",
schema={
vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector(
write=False, state_required=True, valid_dpt="9.001"
),
vol.Required(CONF_GA_SETPOINT_SHIFT): GASelector(
write_required=True,
state_required=True,
dpt=ConfSetpointShiftMode,
),
vol.Required(
ClimateConf.SETPOINT_SHIFT_MIN, default=-6
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-32, max=0, step=1, unit_of_measurement="K"
)
),
vol.Required(
ClimateConf.SETPOINT_SHIFT_MAX, default=6
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, max=32, step=1, unit_of_measurement="K"
)
),
vol.Required(
ClimateConf.TEMPERATURE_STEP, default=0.1
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1, max=2, step=0.1, unit_of_measurement="K"
),
),
},
),
collapsible=False,
),
"section_activity": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_ACTIVE): GASelector(write=False, valid_dpt="1"),
vol.Optional(CONF_GA_VALVE): GASelector(write=False, valid_dpt="5.001"),
"section_operation_mode": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_OPERATION_MODE): GASelector(valid_dpt="20.102"),
vol.Optional(CONF_IGNORE_AUTO_MODE): selector.BooleanSelector(),
"section_operation_mode_individual": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_OP_MODE_COMFORT): GASelector(state=False, valid_dpt="1"),
vol.Optional(CONF_GA_OP_MODE_ECO): GASelector(state=False, valid_dpt="1"),
vol.Optional(CONF_GA_OP_MODE_STANDBY): GASelector(state=False, valid_dpt="1"),
vol.Optional(CONF_GA_OP_MODE_PROTECTION): GASelector(
state=False, valid_dpt="1"
),
"section_heat_cool": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_HEAT_COOL): GASelector(valid_dpt="1.100"),
"section_on_off": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_ON_OFF): GASelector(valid_dpt="1"),
vol.Optional(ClimateConf.ON_OFF_INVERT): selector.BooleanSelector(),
"section_controller_mode": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_CONTROLLER_MODE): GASelector(valid_dpt="20.105"),
vol.Optional(CONF_GA_CONTROLLER_STATUS): GASelector(write=False),
vol.Required(
ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(HVACMode),
translation_key="component.climate.selector.hvac_mode",
)
),
"section_fan": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_GA_FAN_SPEED): GASelector(dpt=ConfClimateFanSpeedMode),
vol.Required(ClimateConf.FAN_MAX_STEP, default=3): AllSerializeFirst(
selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=100, step=1)
),
vol.Coerce(int),
),
vol.Required(
ClimateConf.FAN_ZERO_MODE, default=FanZeroMode.OFF
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(FanZeroMode),
translation_key="component.knx.config_panel.entities.create.climate.knx.fan_zero_mode",
)
),
vol.Optional(CONF_GA_FAN_SWING): GASelector(valid_dpt="1"),
vol.Optional(CONF_GA_FAN_SWING_HORIZONTAL): GASelector(valid_dpt="1"),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
},
)
KNX_SCHEMA_FOR_PLATFORM = {
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
Platform.COVER: COVER_KNX_SCHEMA,
Platform.LIGHT: LIGHT_KNX_SCHEMA,
Platform.SWITCH: SWITCH_KNX_SCHEMA,

View File

@@ -269,7 +269,7 @@
},
"type": {
"name": "Value type",
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see {sensor_value_types_url})."
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
},
"response": {
"name": "Send as Response",
@@ -297,7 +297,7 @@
},
"type": {
"name": "Value type",
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see {sensor_value_types_url})."
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
},
"remove": {
"name": "Remove event registration",
@@ -315,7 +315,7 @@
},
"type": {
"name": "Value type",
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see {sensor_value_types_url})."
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)."
},
"entity_id": {
"name": "Entity",
@@ -412,164 +412,6 @@
}
}
},
"climate": {
"description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.",
"knx": {
"ga_temperature_current": {
"label": "Current temperature"
},
"ga_humidity_current": {
"label": "Current humidity"
},
"target_temperature": {
"title": "Target temperature",
"description": "Set the target temperature.",
"options": {
"group_direct_temp": {
"label": "Absolute setpoint",
"description": "Set the target temperature by an absolute value."
},
"group_setpoint_shift": {
"label": "Setpoint shift",
"description": "Shift the target temperature from a base setpoint."
}
},
"ga_temperature_target": {
"label": "Target temperature",
"description": "Current absolute target temperature."
},
"min_temp": {
"label": "Minimum temperature",
"description": "Minimum temperature that can be set."
},
"max_temp": {
"label": "Maximum temperature",
"description": "Maximum temperature that can be set."
},
"temperature_step": {
"label": "Temperature step",
"description": "Smallest step size to change the temperature. For setpoint shift configurations this sets the scale factor of the shift value."
},
"ga_setpoint_shift": {
"label": "Setpoint shift",
"description": "Target temperature deviation from a base setpoint."
},
"setpoint_shift_min": {
"label": "Minimum setpoint shift",
"description": "Lowest allowed deviation from the base setpoint."
},
"setpoint_shift_max": {
"label": "Maximum setpoint shift",
"description": "Highest allowed deviation from the base setpoint."
}
},
"section_activity": {
"title": "Activity",
"description": "Determine if the device is active or idle."
},
"ga_active": {
"label": "Active",
"description": "Binary value indicating if the device is active or idle. If configured, this takes precedence over valve position."
},
"ga_valve": {
"label": "Valve position",
"description": "Current control value / valve position in percent. `0` sets the climate entity to idle."
},
"section_operation_mode": {
"title": "Operation mode",
"description": "Set the preset mode of the device."
},
"ga_operation_mode": {
"label": "Operation mode",
"description": "Current operation mode."
},
"ignore_auto_mode": {
"label": "Ignore auto mode",
"description": "Enable when your controller doesn't support `auto` mode. It will be ignored by the integration then."
},
"section_operation_mode_individual": {
"title": "Individual operation modes",
"description": "Set the preset mode of the device using individual group addresses."
},
"ga_operation_mode_comfort": {
"label": "Comfort mode"
},
"ga_operation_mode_economy": {
"label": "Economy mode"
},
"ga_operation_mode_standby": {
"label": "Standby mode"
},
"ga_operation_mode_protection": {
"label": "Building protection mode"
},
"section_heat_cool": {
"title": "Heating/Cooling",
"description": "Set whether the device is in heating or cooling mode."
},
"ga_heat_cool": {
"label": "Heating/Cooling"
},
"section_on_off": {
"title": "On/Off",
"description": "Turn the device on or off."
},
"ga_on_off": {
"label": "On/Off"
},
"on_off_invert": {
"label": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::label%]",
"description": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::description%]"
},
"section_controller_mode": {
"title": "Controller mode",
"description": "Set the mode of the climate device."
},
"ga_controller_mode": {
"label": "Controller mode"
},
"ga_controller_status": {
"label": "Controller status",
"description": "HVAC controller mode and preset status. Eberle Status octet (KNX AN 097/07 rev 3) non-standardized DPT."
},
"default_controller_mode": {
"label": "Default mode",
"description": "Climate mode to be set on initialization."
},
"section_fan": {
"title": "Fan",
"description": "Configuration for fan control (AC units)."
},
"ga_fan_speed": {
"label": "Fan speed",
"description": "Set the current fan speed.",
"options": {
"5_001": "Percent",
"5_010": "Steps"
}
},
"fan_max_step": {
"label": "Fan steps",
"description": "The maximum amount of steps for the fan."
},
"fan_zero_mode": {
"label": "Zero fan speed mode",
"description": "Set the mode that represents fan speed `0`.",
"options": {
"off": "[%key:common::state::off%]",
"auto": "[%key:common::state::auto%]"
}
},
"ga_fan_swing": {
"label": "Fan swing",
"description": "Toggle (vertical) fan swing mode. Use this if only one direction is supported."
},
"ga_fan_swing_horizontal": {
"label": "Fan horizontal swing",
"description": "Toggle horizontal fan swing mode."
}
}
},
"cover": {
"description": "The KNX cover platform is used as an interface to shutter actuators.",
"knx": {

View File

@@ -18,7 +18,6 @@ from homeassistant.const import (
CONF_SOURCE,
CONF_UNIT_OF_MEASUREMENT,
LIGHT_LUX,
PERCENTAGE,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfSpeed,
@@ -51,7 +50,6 @@ DEVICE_CLASS_MAPPING = {
pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE,
pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT,
pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2,
pypck.lcn_defs.VarUnit.PERCENT: SensorDeviceClass.HUMIDITY,
}
UNIT_OF_MEASUREMENT_MAPPING = {
@@ -64,7 +62,6 @@ UNIT_OF_MEASUREMENT_MAPPING = {
pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT,
pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE,
pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION,
pypck.lcn_defs.VarUnit.PERCENT: PERCENTAGE,
}

View File

@@ -5,17 +5,11 @@ from __future__ import annotations
import asyncio
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
LightEntity,
brightness_supported,
)
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
@@ -48,10 +42,8 @@ class LunatoneLight(
):
"""Representation of a Lunatone light."""
BRIGHTNESS_SCALE = (1, 100)
_last_brightness = 255
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
@@ -90,25 +82,6 @@ class LunatoneLight(
"""Return True if light is on."""
return self._device is not None and self._device.is_on
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
if self._device is None:
return 0
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._device is not None and self._device.is_dimmable:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Return the supported color modes."""
return {self.color_mode}
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@@ -118,27 +91,13 @@ class LunatoneLight(
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
assert self._device
if brightness_supported(self.supported_color_modes):
brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness)
await self._device.fade_to_brightness(
brightness_to_value(self.BRIGHTNESS_SCALE, brightness)
)
else:
await self._device.switch_on()
await self._device.switch_on()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
assert self._device
if brightness_supported(self.supported_color_modes):
self._last_brightness = self.brightness
await self._device.fade_to_brightness(0)
else:
await self._device.switch_off()
await self._device.switch_off()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()

View File

@@ -20,14 +20,13 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import ConfigType
from .adapter import MatterAdapter
from .addon import get_addon_manager
@@ -41,13 +40,10 @@ from .helpers import (
node_from_ha_device_id,
)
from .models import MatterDeviceInfo
from .services import async_setup_services
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@callback
@cache
@@ -68,12 +64,6 @@ def get_matter_device_info(
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Matter integration services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Matter from a config entry."""
if use_addon := entry.data.get(CONF_USE_ADDON):

View File

@@ -155,18 +155,4 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,),
value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id,
),
MatterDiscoverySchema(
platform=Platform.BUTTON,
entity_description=MatterButtonEntityDescription(
key="WaterHeaterManagementCancelBoost",
translation_key="cancel_boost",
command=clusters.WaterHeaterManagement.Commands.CancelBoost,
),
entity_class=MatterCommandButton,
required_attributes=(
clusters.WaterHeaterManagement.Attributes.AcceptedCommandList,
),
value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id,
allow_multi=True, # Also used in water_heater
),
]

View File

@@ -123,9 +123,6 @@
"evse_fault_state": {
"default": "mdi:ev-station"
},
"operational_error": {
"default": "mdi:alert-circle"
},
"pump_control_mode": {
"default": "mdi:pipe-wrench"
},
@@ -163,10 +160,5 @@
"default": "mdi:shield-lock"
}
}
},
"services": {
"water_heater_boost": {
"service": "mdi:water-boiler"
}
}
}

View File

@@ -86,14 +86,6 @@ OPERATIONAL_STATE_MAP = {
clusters.OperationalState.Enums.OperationalStateEnum.kError: "error",
}
OPERATIONAL_STATE_ERROR_MAP = {
# enum with known Error state values which we can translate
clusters.OperationalState.Enums.ErrorStateEnum.kNoError: "no_error",
clusters.OperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume",
clusters.OperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation",
clusters.OperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state",
}
RVC_OPERATIONAL_STATE_MAP = {
# enum with known Operation state values which we can translate
**OPERATIONAL_STATE_MAP,
@@ -102,29 +94,6 @@ RVC_OPERATIONAL_STATE_MAP = {
clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked",
}
RVC_OPERATIONAL_STATE_ERROR_MAP = {
# enum with known Error state values which we can translate
clusters.RvcOperationalState.Enums.ErrorStateEnum.kNoError: "no_error",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToStartOrResume: "unable_to_start_or_resume",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kUnableToCompleteOperation: "unable_to_complete_operation",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kCommandInvalidInState: "command_invalid_in_state",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kFailedToFindChargingDock: "failed_to_find_charging_dock",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kStuck: "stuck",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinMissing: "dust_bin_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDustBinFull: "dust_bin_full",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankEmpty: "water_tank_empty",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankMissing: "water_tank_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWaterTankLidOpen: "water_tank_lid_open",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kMopCleaningPadMissing: "mop_cleaning_pad_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kLowBattery: "low_battery",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kCannotReachTargetArea: "cannot_reach_target_area",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankFull: "dirty_water_tank_full",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kDirtyWaterTankMissing: "dirty_water_tank_missing",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kWheelsJammed: "wheels_jammed",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kBrushJammed: "brush_jammed",
clusters.RvcOperationalState.Enums.ErrorStateEnum.kNavigationSensorObscured: "navigation_sensor_obscured",
}
BOOST_STATE_MAP = {
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive",
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active",
@@ -1132,19 +1101,6 @@ DISCOVERY_SCHEMAS = [
# don't discover this entry if the supported state list is empty
secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="OperationalStateOperationalError",
translation_key="operational_error",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(OPERATIONAL_STATE_ERROR_MAP.values()),
device_to_ha=lambda x: OPERATIONAL_STATE_ERROR_MAP.get(x.errorStateID),
),
entity_class=MatterSensor,
required_attributes=(clusters.OperationalState.Attributes.OperationalError,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterListSensorEntityDescription(
@@ -1189,19 +1145,6 @@ DISCOVERY_SCHEMAS = [
device_type=(device_types.Thermostat,),
allow_multi=True, # also used for climate entity
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ThermostatPIHeatingDemand",
translation_key="pi_heating_demand",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.PIHeatingDemand,),
device_type=(device_types.Thermostat,),
featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kHeating,
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
@@ -1238,19 +1181,6 @@ DISCOVERY_SCHEMAS = [
# don't discover this entry if the supported state list is empty
secondary_value_is_not=[],
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="RvcOperationalStateOperationalError",
translation_key="operational_error",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(RVC_OPERATIONAL_STATE_ERROR_MAP.values()),
device_to_ha=lambda x: RVC_OPERATIONAL_STATE_ERROR_MAP.get(x.errorStateID),
),
entity_class=MatterSensor,
required_attributes=(clusters.RvcOperationalState.Attributes.OperationalError,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterOperationalStateSensorEntityDescription(

View File

@@ -1,38 +0,0 @@
"""Services for Matter devices."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ATTR_DURATION = "duration"
ATTR_EMERGENCY_BOOST = "emergency_boost"
ATTR_TEMPORARY_SETPOINT = "temporary_setpoint"
SERVICE_WATER_HEATER_BOOST = "water_heater_boost"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the Matter services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_WATER_HEATER_BOOST,
entity_domain=WATER_HEATER_DOMAIN,
schema={
# duration >=1
vol.Required(ATTR_DURATION): vol.All(vol.Coerce(int), vol.Range(min=1)),
vol.Optional(ATTR_EMERGENCY_BOOST): cv.boolean,
vol.Optional(ATTR_TEMPORARY_SETPOINT): vol.All(
vol.Coerce(int), vol.Range(min=30, max=65)
),
},
func="async_set_boost",
)

View File

@@ -1,26 +0,0 @@
water_heater_boost:
target:
entity:
domain: water_heater
fields:
duration:
selector:
number:
min: 60
max: 14400
step: 60
mode: box
default: 3600
required: true
emergency_boost:
selector:
boolean:
default: false
temporary_setpoint:
selector:
number:
min: 30
max: 65
step: 1
mode: slider
default: 65

View File

@@ -106,9 +106,6 @@
}
},
"button": {
"cancel_boost": {
"name": "Cancel boost"
},
"pause": {
"name": "[%key:common::action::pause%]"
},
@@ -444,33 +441,6 @@
"evse_soc": {
"name": "State of charge"
},
"operational_error": {
"name": "Operational error",
"state": {
"no_error": "No error",
"unable_to_start_or_resume": "Unable to start or resume",
"unable_to_complete_operation": "Unable to complete operation",
"command_invalid_in_state": "Command invalid in current state",
"failed_to_find_charging_dock": "Failed to find charging dock",
"stuck": "Stuck",
"dust_bin_missing": "Dust bin missing",
"dust_bin_full": "Dust bin full",
"water_tank_empty": "Water tank empty",
"water_tank_missing": "Water tank missing",
"water_tank_lid_open": "Water tank lid open",
"mop_cleaning_pad_missing": "Mop cleaning pad missing",
"low_battery": "Low battery",
"cannot_reach_target_area": "Cannot reach target area",
"dirty_water_tank_full": "Dirty water tank full",
"dirty_water_tank_missing": "Dirty water tank missing",
"wheels_jammed": "Wheels jammed",
"brush_jammed": "Brush jammed",
"navigation_sensor_obscured": "Navigation sensor obscured"
}
},
"pi_heating_demand": {
"name": "Heating demand"
},
"nitrogen_dioxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
},
@@ -593,24 +563,6 @@
"description": "The Matter device to add to the other Matter network."
}
}
},
"water_heater_boost": {
"name": "Boost water heater",
"description": "Enables water heater boost for a specific duration.",
"fields": {
"duration": {
"name": "Duration",
"description": "Boost duration"
},
"emergency_boost": {
"name": "Emergency boost",
"description": "Whether to enable emergency boost mode."
},
"temporary_setpoint": {
"name": "Temporary setpoint",
"description": "Temporary setpoint temperature in Celsius during the boost period."
}
}
}
}
}

View File

@@ -6,9 +6,7 @@ from dataclasses import dataclass
from typing import Any, cast
from chip.clusters import Objects as clusters
from chip.clusters.Types import Nullable
from matter_server.client.models import device_types
from matter_server.common.errors import MatterError
from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.water_heater import (
@@ -27,7 +25,6 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
@@ -43,8 +40,6 @@ WATER_HEATER_SYSTEM_MODE_MAP = {
STATE_OFF: 0,
}
DEFAULT_BOOST_DURATION = 3600 # 1 hour
async def async_setup_entry(
hass: HomeAssistant,
@@ -83,30 +78,6 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_platform_translation_key = "water_heater"
async def async_set_boost(
self,
duration: int,
emergency_boost: bool = False,
temporary_setpoint: int | None = None,
) -> None:
"""Set boost."""
boost_info: clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
duration=duration,
emergencyBoost=emergency_boost,
temporarySetpoint=(
temporary_setpoint * TEMPERATURE_SCALING_FACTOR
if temporary_setpoint is not None
else Nullable
),
)
try:
await self.send_device_command(
clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info)
)
except MatterError as err:
raise HomeAssistantError(f"Error sending Boost command: {err}") from err
self._update_from_device()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE)
@@ -123,11 +94,11 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity):
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
self._attr_current_operation = operation_mode
# Use the constant for boost duration
# Boost 1h (3600s)
boost_info: type[
clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct
] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct(
duration=DEFAULT_BOOST_DURATION
duration=3600
)
system_mode_value = WATER_HEATER_SYSTEM_MODE_MAP[operation_mode]
await self.write_attribute(

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.10.22"],
"requirements": ["yt-dlp[default]==2025.09.26"],
"single_config_entry": true
}

View File

@@ -270,13 +270,10 @@ class MetOfficeWeather(
self.forecast_coordinators["daily"],
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
return [
_build_daily_forecast_data(timestep)
for timestep in timesteps
if timestep["time"] >= start_datetime
if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo)
]
@callback
@@ -288,13 +285,10 @@ class MetOfficeWeather(
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
minute=0, second=0, microsecond=0
)
return [
_build_hourly_forecast_data(timestep)
for timestep in timesteps
if timestep["time"] >= start_datetime
if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo)
]
@callback
@@ -305,11 +299,8 @@ class MetOfficeWeather(
self.forecast_coordinators["twice_daily"],
)
timesteps = coordinator.data.timesteps
start_datetime = datetime.now(tz=timesteps[0]["time"].tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
return [
_build_twice_daily_forecast_data(timestep)
for timestep in timesteps
if timestep["time"] >= start_datetime
if timestep["time"] > datetime.now(tz=timesteps[0]["time"].tzinfo)
]

View File

@@ -248,7 +248,7 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -
Causes for this is config entry options changing.
"""
hass.config_entries.async_schedule_reload(entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)
@callback

View File

@@ -3815,7 +3815,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
try_connection,
new_entry_data,
):
return self.async_update_and_abort(reauth_entry, data=new_entry_data)
return self.async_update_reload_and_abort(
reauth_entry, data=new_entry_data
)
errors["base"] = "invalid_auth"
@@ -3861,7 +3863,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if can_connect:
if is_reconfigure:
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
reconfigure_entry,
data=validated_user_input,
)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "legacy",
"requirements": ["nsapi==3.1.3"]
"requirements": ["nsapi==3.1.2"]
}

View File

@@ -14,7 +14,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_PROFILE_ID, DOMAIN
@@ -24,40 +23,11 @@ AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
_LOGGER = logging.getLogger(__name__)
async def async_init_nextdns(
hass: HomeAssistant, api_key: str, profile_id: str | None = None
) -> NextDns:
"""Check if credentials and profile_id are valid."""
async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns:
"""Check if credentials are valid."""
websession = async_get_clientsession(hass)
nextdns = await NextDns.create(websession, api_key)
if profile_id:
if not any(profile.id == profile_id for profile in nextdns.profiles):
raise ProfileNotAvailable
return nextdns
async def async_validate_new_api_key(
hass: HomeAssistant, user_input: dict[str, Any], profile_id: str
) -> dict[str, str]:
"""Validate the new API key during reconfiguration or reauth."""
errors: dict[str, str] = {}
try:
await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id)
except InvalidApiKeyError:
errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
errors["base"] = "cannot_connect"
except ProfileNotAvailable:
errors["base"] = "profile_not_available"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors
return await NextDns.create(websession, api_key)
class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -137,19 +107,20 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
if user_input is not None:
errors = await async_validate_new_api_key(
self.hass, user_input, entry.data[CONF_PROFILE_ID]
)
if errors.get("base") == "profile_not_available":
return self.async_abort(reason="profile_not_available")
if not errors:
try:
await async_init_nextdns(self.hass, user_input[CONF_API_KEY])
except InvalidApiKeyError:
errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
entry,
data_updates=user_input,
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
@@ -157,33 +128,3 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=AUTH_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
errors = await async_validate_new_api_key(
self.hass, user_input, entry.data[CONF_PROFILE_ID]
)
if errors.get("base") == "profile_not_available":
return self.async_abort(reason="profile_not_available")
if not errors:
return self.async_update_reload_and_abort(
entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reconfigure",
data_schema=AUTH_SCHEMA,
errors=errors,
)
class ProfileNotAvailable(HomeAssistantError):
"""Error to indicate that the profile is not available after reconfig/reauth."""

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["nextdns"],
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["nextdns==4.1.0"]
}

View File

@@ -48,17 +48,19 @@ rules:
discovery:
status: exempt
comment: The integration is a cloud service and thus does not support discovery.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations:
status: todo
comment: Add info that there are no known limitations.
docs-supported-devices:
status: exempt
comment: This is a service, which doesn't integrate with any devices.
docs-supported-functions: done
docs-supported-functions: todo
docs-troubleshooting:
status: exempt
comment: No known issues that could be resolved by the user.
docs-use-cases: done
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: This integration has a fixed single service.
@@ -68,7 +70,9 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow:
status: todo
comment: Allow API key to be changed in the re-configure flow.
repair-issues:
status: exempt
comment: This integration doesn't have any cases where raising an issue is needed.

View File

@@ -24,14 +24,6 @@
"data_description": {
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
}
},
"reconfigure": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]"
}
}
},
"error": {
@@ -41,9 +33,7 @@
},
"abort": {
"already_configured": "This NextDNS profile is already configured.",
"profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"system_health": {

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er
from .const import _LOGGER
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE]
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT]
type NikoHomeControlConfigEntry = ConfigEntry[NHCController]

View File

@@ -1,40 +0,0 @@
"""Scene Platform for Niko Home Control."""
from __future__ import annotations
from typing import Any
from homeassistant.components.scene import BaseScene
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NikoHomeControlConfigEntry
from .entity import NikoHomeControlEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NikoHomeControlConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Niko Home Control scene entry."""
controller = entry.runtime_data
async_add_entities(
NikoHomeControlScene(scene, controller, entry.entry_id)
for scene in controller.scenes
)
class NikoHomeControlScene(NikoHomeControlEntity, BaseScene):
"""Representation of a Niko Home Control Scene."""
_attr_name = None
async def _async_activate(self, **kwargs: Any) -> None:
"""Activate scene. Try to get entities into requested state."""
await self._action.activate()
def update_state(self) -> None:
"""Update HA state."""
self._async_record_activation()

View File

@@ -2,17 +2,13 @@
from __future__ import annotations
from typing import Any
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import (
_LOGGER,
ALL_MATCH_REGEX,
CONF_AREA_FILTER,
CONF_FILTER_CORONA,
CONF_FILTERS,
CONF_HEADLINE_FILTER,
NO_MATCH_REGEX,
)
@@ -23,6 +19,20 @@ PLATFORMS: list[str] = [Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool:
"""Set up platform from a ConfigEntry."""
if CONF_HEADLINE_FILTER not in entry.data:
filter_regex = NO_MATCH_REGEX
if entry.data[CONF_FILTER_CORONA]:
filter_regex = ".*corona.*"
new_data = {**entry.data, CONF_HEADLINE_FILTER: filter_regex}
new_data.pop(CONF_FILTER_CORONA, None)
hass.config_entries.async_update_entry(entry, data=new_data)
if CONF_AREA_FILTER not in entry.data:
new_data = {**entry.data, CONF_AREA_FILTER: ALL_MATCH_REGEX}
hass.config_entries.async_update_entry(entry, data=new_data)
coordinator = NINADataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
@@ -37,52 +47,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool:
"""Migrate the config to the new format."""
version = entry.version
minor_version = entry.minor_version
_LOGGER.debug("Migrating from version %s.%s", version, minor_version)
if entry.version > 1:
# This means the user has downgraded from a future version
return False
new_data: dict[str, Any] = {**entry.data, CONF_FILTERS: {}}
if version == 1 and minor_version == 1:
if CONF_HEADLINE_FILTER not in entry.data:
filter_regex = NO_MATCH_REGEX
if entry.data.get(CONF_FILTER_CORONA, None):
filter_regex = ".*corona.*"
new_data[CONF_HEADLINE_FILTER] = filter_regex
new_data.pop(CONF_FILTER_CORONA, None)
if CONF_AREA_FILTER not in entry.data:
new_data[CONF_AREA_FILTER] = ALL_MATCH_REGEX
hass.config_entries.async_update_entry(
entry,
data=new_data,
minor_version=2,
)
minor_version = 2
if version == 1 and minor_version == 2:
new_data[CONF_FILTERS][CONF_HEADLINE_FILTER] = entry.data[CONF_HEADLINE_FILTER]
new_data.pop(CONF_HEADLINE_FILTER, None)
new_data[CONF_FILTERS][CONF_AREA_FILTER] = entry.data[CONF_AREA_FILTER]
new_data.pop(CONF_AREA_FILTER, None)
hass.config_entries.async_update_entry(
entry,
data=new_data,
minor_version=3,
)
return True

View File

@@ -14,16 +14,13 @@ from homeassistant.config_entries import (
OptionsFlowWithReload,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import VolDictType
from .const import (
_LOGGER,
ALL_MATCH_REGEX,
CONF_AREA_FILTER,
CONF_FILTERS,
CONF_HEADLINE_FILTER,
CONF_MESSAGE_SLOTS,
CONF_REGIONS,
@@ -90,7 +87,6 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for NINA."""
VERSION: int = 1
MINOR_VERSION: int = 3
def __init__(self) -> None:
"""Initialize."""
@@ -130,8 +126,8 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN):
if group_input := user_input.get(group):
user_input[CONF_REGIONS] += group_input
if not user_input[CONF_FILTERS][CONF_HEADLINE_FILTER]:
user_input[CONF_FILTERS][CONF_HEADLINE_FILTER] = NO_MATCH_REGEX
if not user_input[CONF_HEADLINE_FILTER]:
user_input[CONF_HEADLINE_FILTER] = NO_MATCH_REGEX
if user_input[CONF_REGIONS]:
return self.async_create_entry(
@@ -154,18 +150,7 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_MESSAGE_SLOTS, default=5): vol.All(
int, vol.Range(min=1, max=20)
),
vol.Required(CONF_FILTERS): section(
vol.Schema(
{
vol.Optional(
CONF_HEADLINE_FILTER, default=NO_MATCH_REGEX
): cv.string,
vol.Optional(
CONF_AREA_FILTER, default=ALL_MATCH_REGEX
): cv.string,
}
)
),
vol.Optional(CONF_HEADLINE_FILTER, default=""): cv.string,
}
),
errors=errors,
@@ -274,20 +259,14 @@ class OptionsFlowHandler(OptionsFlowWithReload):
CONF_MESSAGE_SLOTS,
default=self.data[CONF_MESSAGE_SLOTS],
): vol.All(int, vol.Range(min=1, max=20)),
vol.Required(CONF_FILTERS): section(
vol.Schema(
{
vol.Optional(
CONF_HEADLINE_FILTER,
default=self.data[CONF_FILTERS][CONF_HEADLINE_FILTER],
): cv.string,
vol.Optional(
CONF_AREA_FILTER,
default=self.data[CONF_FILTERS][CONF_AREA_FILTER],
): cv.string,
}
)
),
vol.Optional(
CONF_HEADLINE_FILTER,
default=self.data[CONF_HEADLINE_FILTER],
): cv.string,
vol.Optional(
CONF_AREA_FILTER,
default=self.data[CONF_AREA_FILTER],
): cv.string,
}
return self.async_show_form(

View File

@@ -17,7 +17,6 @@ ALL_MATCH_REGEX: str = ".*"
CONF_REGIONS: str = "regions"
CONF_MESSAGE_SLOTS: str = "slots"
CONF_FILTERS: str = "filters"
CONF_FILTER_CORONA: str = "corona_filter" # deprecated
CONF_HEADLINE_FILTER: str = "headline_filter"
CONF_AREA_FILTER: str = "area_filter"

View File

@@ -17,7 +17,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
_LOGGER,
CONF_AREA_FILTER,
CONF_FILTERS,
CONF_HEADLINE_FILTER,
CONF_REGIONS,
DOMAIN,
@@ -59,10 +58,8 @@ class NINADataUpdateCoordinator(
) -> None:
"""Initialize."""
self._nina: Nina = Nina(async_get_clientsession(hass))
self.headline_filter: str = config_entry.data[CONF_FILTERS][
CONF_HEADLINE_FILTER
]
self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER]
self.headline_filter: str = config_entry.data[CONF_HEADLINE_FILTER]
self.area_filter: str = config_entry.data[CONF_AREA_FILTER]
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
for region in regions:

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