Compare commits

..

2 Commits

Author SHA1 Message Date
Stefan Agner
ecf3bc85a1 Merge branch 'dev' into add-deprecated-arch-addon-repair 2026-03-16 13:47:05 +01:00
Mike Degatano
bd9d323c46 Add repair for deprecated arch addon issue 2026-03-13 23:06:35 +00:00
235 changed files with 8081 additions and 19908 deletions

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.02.0"
BASE_IMAGE_VERSION: "2026.01.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
@@ -72,7 +72,7 @@ jobs:
- name: Download Translations
run: python3 -m script.translations download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Archive translations
shell: bash

View File

@@ -1400,7 +1400,7 @@ jobs:
with:
fail_ci_if_error: true
flags: full-suite
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
token: ${{ secrets.CODECOV_TOKEN }}
pytest-partial:
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
@@ -1570,7 +1570,7 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
token: ${{ secrets.CODECOV_TOKEN }}
upload-test-results:
name: Upload test results to Codecov

View File

@@ -58,8 +58,8 @@ jobs:
# v1.7.0
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
with:
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
# The 90 day stale policy for issues
# Used for:

View File

@@ -33,6 +33,6 @@ jobs:
- name: Upload Translations
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
run: |
python3 -m script.translations upload

View File

@@ -142,7 +142,7 @@ jobs:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
@@ -200,7 +200,7 @@ jobs:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl

View File

@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.23.1
rev: v1.22.0
hooks:
- id: zizmor
args:

View File

@@ -1 +1 @@
3.14.3
3.14.2

2
CODEOWNERS generated
View File

@@ -974,8 +974,6 @@ build.json @home-assistant/supervisor
/tests/components/logbook/ @home-assistant/core
/homeassistant/components/logger/ @home-assistant/core
/tests/components/logger/ @home-assistant/core
/homeassistant/components/lojack/ @devinslick
/tests/components/lojack/ @devinslick
/homeassistant/components/london_underground/ @jpbede
/tests/components/london_underground/ @jpbede
/homeassistant/components/lookin/ @ANMalko @bdraco

View File

@@ -1,8 +1,11 @@
"""Provides conditions for climates."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from homeassistant.helpers.condition import (
Condition,
make_entity_state_attribute_condition,
make_entity_state_condition,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
@@ -19,14 +22,14 @@ CONDITIONS: dict[str, type[Condition]] = {
HVACMode.HEAT_COOL,
},
),
"is_cooling": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
"is_cooling": make_entity_state_attribute_condition(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"is_drying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
"is_drying": make_entity_state_attribute_condition(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
"is_heating": make_entity_state_attribute_condition(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}

View File

@@ -13,6 +13,7 @@ from homeassistant.helpers.trigger import (
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -46,11 +47,11 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
@@ -79,8 +80,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
HVACMode.HEAT_COOL,
},
),
"started_heating": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
"started_heating": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}

View File

@@ -38,9 +38,9 @@ from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import format_unserializable_data
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
from .util import async_redact_data, entity_entry_as_dict
from .util import async_redact_data
__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
__all__ = ["REDACTED", "async_redact_data"]
_LOGGER = logging.getLogger(__name__)

View File

@@ -5,10 +5,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any, cast, overload
import attr
from homeassistant.core import callback
from homeassistant.helpers.entity_registry import RegistryEntry
from .const import REDACTED
@@ -45,16 +42,3 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
redacted[key] = [async_redact_data(item, to_redact) for item in value]
return cast(_T, redacted)
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
return a.name != "_cache"
@callback
def entity_entry_as_dict(entry: RegistryEntry) -> dict[str, Any]:
"""Convert an entity registry entry to a dict for diagnostics.
This excludes internal fields that should not be exposed in diagnostics.
"""
return attr.asdict(entry, filter=_entity_entry_filter)

View File

@@ -11,7 +11,7 @@ from attr import asdict
from pyenphase.envoy import Envoy
from pyenphase.exceptions import EnvoyError
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@@ -111,7 +111,8 @@ async def async_get_config_entry_diagnostics(
if state := hass.states.get(entity.entity_id):
state_dict = dict(state.as_dict())
state_dict.pop("context", None)
entity_dict = entity_entry_as_dict(entity)
entity_dict = asdict(entity)
entity_dict.pop("_cache", None)
entities.append({"entity": entity_dict, "state": state_dict})
device_dict = asdict(device)
device_dict.pop("_cache", None)

View File

@@ -239,9 +239,6 @@ def _login_classic_api(
return login_response
V1_DEVICE_TYPES: dict[int, str] = {5: "sph", 7: "min"}
def get_device_list_v1(
api, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
@@ -263,17 +260,18 @@ def get_device_list_v1(
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
supported_devices = [
{
"deviceSn": device.get("device_sn", ""),
"deviceType": V1_DEVICE_TYPES[device.get("type")],
"deviceType": "min",
}
for device in devices
if device.get("type") in V1_DEVICE_TYPES
if device.get("type") == 7
]
for device in devices:
if device.get("type") not in V1_DEVICE_TYPES:
if device.get("type") != 7:
_LOGGER.warning(
"Device %s with type %s not supported in Open API V1, skipping",
device.get("device_sn", ""),
@@ -350,7 +348,7 @@ async def async_setup_entry(
hass, config_entry, device["deviceSn"], device["deviceType"], plant_id
)
for device in devices
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min", "sph"]
if device["deviceType"] in ["inverter", "tlx", "storage", "mix", "min"]
}
# Perform the first refresh for the total coordinator

View File

@@ -167,36 +167,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
**storage_info_detail["storageDetailBean"],
**storage_energy_overview,
}
elif self.device_type == "sph":
try:
sph_detail = self.api.sph_detail(self.device_id)
sph_energy = self.api.sph_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(f"Error fetching SPH device data: {err}") from err
combined = {**sph_detail, **sph_energy}
# Parse last update timestamp from sph_energy "time" field
time_str = sph_energy.get("time")
if time_str:
try:
parsed = datetime.datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
combined["lastdataupdate"] = parsed.replace(
tzinfo=dt_util.get_default_time_zone()
)
except ValueError, TypeError:
_LOGGER.debug(
"Could not parse SPH time field for %s: %r",
self.device_id,
time_str,
)
self.data = combined
_LOGGER.debug("sph_info for device %s: %r", self.device_id, self.data)
elif self.device_type == "mix":
mix_info = self.api.mix_info(self.device_id)
mix_totals = self.api.mix_totals(self.device_id, self.plant_id)
@@ -478,123 +448,3 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return "00:00"
else:
return f"{hour:02d}:{minute:02d}"
async def update_ac_charge_times(
self,
charge_power: int,
charge_stop_soc: int,
mains_enabled: bool,
periods: list[dict],
) -> None:
"""Update AC charge time periods for SPH device.
Args:
charge_power: Charge power limit (0-100 %)
charge_stop_soc: Stop charging at this SOC level (0-100 %)
mains_enabled: Whether AC (mains) charging is enabled
periods: List of up to 3 dicts with keys start_time, end_time, enabled
"""
if self.api_version != "v1":
raise ServiceValidationError(
"Updating AC charge times requires token authentication"
)
try:
await self.hass.async_add_executor_job(
self.api.sph_write_ac_charge_times,
self.device_id,
charge_power,
charge_stop_soc,
mains_enabled,
periods,
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(
f"API error updating AC charge times: {err}"
) from err
if self.data:
self.data["chargePowerCommand"] = charge_power
self.data["wchargeSOCLowLimit"] = charge_stop_soc
self.data["acChargeEnable"] = 1 if mains_enabled else 0
for i, period in enumerate(periods, 1):
self.data[f"forcedChargeTimeStart{i}"] = period["start_time"].strftime(
"%H:%M"
)
self.data[f"forcedChargeTimeStop{i}"] = period["end_time"].strftime(
"%H:%M"
)
self.data[f"forcedChargeStopSwitch{i}"] = (
1 if period.get("enabled", False) else 0
)
self.async_set_updated_data(self.data)
async def update_ac_discharge_times(
self,
discharge_power: int,
discharge_stop_soc: int,
periods: list[dict],
) -> None:
"""Update AC discharge time periods for SPH device.
Args:
discharge_power: Discharge power limit (0-100 %)
discharge_stop_soc: Stop discharging at this SOC level (0-100 %)
periods: List of up to 3 dicts with keys start_time, end_time, enabled
"""
if self.api_version != "v1":
raise ServiceValidationError(
"Updating AC discharge times requires token authentication"
)
try:
await self.hass.async_add_executor_job(
self.api.sph_write_ac_discharge_times,
self.device_id,
discharge_power,
discharge_stop_soc,
periods,
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(
f"API error updating AC discharge times: {err}"
) from err
if self.data:
self.data["disChargePowerCommand"] = discharge_power
self.data["wdisChargeSOCLowLimit"] = discharge_stop_soc
for i, period in enumerate(periods, 1):
self.data[f"forcedDischargeTimeStart{i}"] = period[
"start_time"
].strftime("%H:%M")
self.data[f"forcedDischargeTimeStop{i}"] = period["end_time"].strftime(
"%H:%M"
)
self.data[f"forcedDischargeStopSwitch{i}"] = (
1 if period.get("enabled", False) else 0
)
self.async_set_updated_data(self.data)
async def read_ac_charge_times(self) -> dict:
"""Read AC charge time settings from SPH device cache."""
if self.api_version != "v1":
raise ServiceValidationError(
"Reading AC charge times requires token authentication"
)
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_charge_times(settings_data=self.data)
async def read_ac_discharge_times(self) -> dict:
"""Read AC discharge time settings from SPH device cache."""
if self.api_version != "v1":
raise ServiceValidationError(
"Reading AC discharge times requires token authentication"
)
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_discharge_times(settings_data=self.data)

View File

@@ -1,22 +1,10 @@
{
"services": {
"read_ac_charge_times": {
"service": "mdi:battery-clock-outline"
},
"read_ac_discharge_times": {
"service": "mdi:battery-clock-outline"
},
"read_time_segments": {
"service": "mdi:clock-outline"
},
"update_time_segment": {
"service": "mdi:clock-edit"
},
"write_ac_charge_times": {
"service": "mdi:battery-clock"
},
"write_ac_discharge_times": {
"service": "mdi:battery-clock"
}
}
}

View File

@@ -15,7 +15,6 @@ from ..coordinator import GrowattConfigEntry, GrowattCoordinator
from .inverter import INVERTER_SENSOR_TYPES
from .mix import MIX_SENSOR_TYPES
from .sensor_entity_description import GrowattSensorEntityDescription
from .sph import SPH_SENSOR_TYPES
from .storage import STORAGE_SENSOR_TYPES
from .tlx import TLX_SENSOR_TYPES
from .total import TOTAL_SENSOR_TYPES
@@ -58,8 +57,6 @@ async def async_setup_entry(
sensor_descriptions = list(STORAGE_SENSOR_TYPES)
elif device_coordinator.device_type == "mix":
sensor_descriptions = list(MIX_SENSOR_TYPES)
elif device_coordinator.device_type == "sph":
sensor_descriptions = list(SPH_SENSOR_TYPES)
else:
_LOGGER.debug(
"Device type %s was found but is not supported right now",

View File

@@ -1,291 +0,0 @@
"""Growatt Sensor definitions for the SPH type."""
from __future__ import annotations
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
PERCENTAGE,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
)
from .sensor_entity_description import GrowattSensorEntityDescription
SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
# Values from 'sph_detail' API call
GrowattSensorEntityDescription(
key="mix_statement_of_charge",
translation_key="mix_statement_of_charge",
api_key="bmsSOC",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
GrowattSensorEntityDescription(
key="mix_battery_voltage",
translation_key="mix_battery_voltage",
api_key="vbat",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_pv1_voltage",
translation_key="mix_pv1_voltage",
api_key="vpv1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_pv2_voltage",
translation_key="mix_pv2_voltage",
api_key="vpv2",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_grid_voltage",
translation_key="mix_grid_voltage",
api_key="vac1",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
GrowattSensorEntityDescription(
key="mix_battery_charge",
translation_key="mix_battery_charge",
api_key="pcharge1",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_battery_discharge_w",
translation_key="mix_battery_discharge_w",
api_key="pdischarge1",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_export_to_grid",
translation_key="mix_export_to_grid",
api_key="pacToGridTotal",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_import_from_grid",
translation_key="mix_import_from_grid",
api_key="pacToUserR",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_grid_frequency",
translation_key="sph_grid_frequency",
api_key="fac",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_1",
translation_key="sph_temperature_1",
api_key="temp1",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_2",
translation_key="sph_temperature_2",
api_key="temp2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_3",
translation_key="sph_temperature_3",
api_key="temp3",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_4",
translation_key="sph_temperature_4",
api_key="temp4",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="sph_temperature_5",
translation_key="sph_temperature_5",
api_key="temp5",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
# Values from 'sph_energy' API call
GrowattSensorEntityDescription(
key="mix_wattage_pv_1",
translation_key="mix_wattage_pv_1",
api_key="ppv1",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_wattage_pv_2",
translation_key="mix_wattage_pv_2",
api_key="ppv2",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_wattage_pv_all",
translation_key="mix_wattage_pv_all",
api_key="ppv",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="mix_battery_charge_today",
translation_key="mix_battery_charge_today",
api_key="echarge1Today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_battery_charge_lifetime",
translation_key="mix_battery_charge_lifetime",
api_key="echarge1Total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_battery_discharge_today",
translation_key="mix_battery_discharge_today",
api_key="edischarge1Today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_battery_discharge_lifetime",
translation_key="mix_battery_discharge_lifetime",
api_key="edischarge1Total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_solar_generation_today",
translation_key="mix_solar_generation_today",
api_key="epvtoday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_solar_generation_lifetime",
translation_key="mix_solar_generation_lifetime",
api_key="epvTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_system_production_today",
translation_key="mix_system_production_today",
api_key="esystemtoday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_self_consumption_today",
translation_key="mix_self_consumption_today",
api_key="eselfToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_import_from_grid_today",
translation_key="mix_import_from_grid_today",
api_key="etoUserToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_export_to_grid_today",
translation_key="mix_export_to_grid_today",
api_key="etoGridToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_export_to_grid_lifetime",
translation_key="mix_export_to_grid_lifetime",
api_key="etogridTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_today",
translation_key="mix_load_consumption_today",
api_key="elocalLoadToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_lifetime",
translation_key="mix_load_consumption_lifetime",
api_key="elocalLoadTotal",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
never_resets=True,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_battery_today",
translation_key="mix_load_consumption_battery_today",
api_key="echarge1",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="mix_load_consumption_solar_today",
translation_key="mix_load_consumption_solar_today",
api_key="eChargeToday",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
# Synthetic timestamp from 'time' field in sph_energy response
GrowattSensorEntityDescription(
key="mix_last_update",
translation_key="mix_last_update",
api_key="lastdataupdate",
device_class=SensorDeviceClass.TIMESTAMP,
),
)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, time
from datetime import datetime
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntryState
@@ -21,77 +21,67 @@ if TYPE_CHECKING:
from .coordinator import GrowattCoordinator
def _get_coordinators(
hass: HomeAssistant, device_type: str
) -> dict[str, GrowattCoordinator]:
"""Get all coordinators of a given device type with V1 API."""
coordinators: dict[str, GrowattCoordinator] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state != ConfigEntryState.LOADED:
continue
for coord in entry.runtime_data.devices.values():
if coord.device_type == device_type and coord.api_version == "v1":
coordinators[coord.device_id] = coord
return coordinators
def _get_coordinator(
hass: HomeAssistant, device_id: str, device_type: str
) -> GrowattCoordinator:
"""Get coordinator by device registry ID and device type."""
coordinators = _get_coordinators(hass, device_type)
if not coordinators:
raise ServiceValidationError(
f"No {device_type.upper()} devices with token authentication are configured. "
f"Services require {device_type.upper()} devices with V1 API access."
)
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if not device_entry:
raise ServiceValidationError(f"Device '{device_id}' not found")
serial_number = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
serial_number = identifier[1]
break
if not serial_number:
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device")
if serial_number not in coordinators:
raise ServiceValidationError(
f"{device_type.upper()} device '{serial_number}' not found or not configured for services"
)
return coordinators[serial_number]
def _parse_time_str(time_str: str, field_name: str) -> time:
"""Parse a time string (HH:MM or HH:MM:SS) to a datetime.time object."""
parts = time_str.split(":")
if len(parts) not in (2, 3):
raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format"
)
try:
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format"
) from err
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services for Growatt Server integration."""
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
"""Get all MIN coordinators with V1 API from loaded config entries."""
min_coordinators: dict[str, GrowattCoordinator] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state != ConfigEntryState.LOADED:
continue
# Add MIN coordinators from this entry
for coord in entry.runtime_data.devices.values():
if coord.device_type == "min" and coord.api_version == "v1":
min_coordinators[coord.device_id] = coord
return min_coordinators
def get_coordinator(device_id: str) -> GrowattCoordinator:
"""Get coordinator by device_id.
Args:
device_id: Device registry ID (not serial number)
"""
# Get current coordinators (they may have changed since service registration)
min_coordinators = get_min_coordinators()
if not min_coordinators:
raise ServiceValidationError(
"No MIN devices with token authentication are configured. "
"Services require MIN devices with V1 API access."
)
# Device registry ID provided - map to serial number
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if not device_entry:
raise ServiceValidationError(f"Device '{device_id}' not found")
# Extract serial number from device identifiers
serial_number = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
serial_number = identifier[1]
break
if not serial_number:
raise ServiceValidationError(
f"Device '{device_id}' is not a Growatt device"
)
# Find coordinator by serial number
if serial_number not in min_coordinators:
raise ServiceValidationError(
f"MIN device '{serial_number}' not found or not configured for services"
)
return min_coordinators[serial_number]
async def handle_update_time_segment(call: ServiceCall) -> None:
"""Handle update_time_segment service call."""
segment_id: int = int(call.data["segment_id"])
@@ -101,11 +91,13 @@ def async_setup_services(hass: HomeAssistant) -> None:
enabled: bool = call.data["enabled"]
device_id: str = call.data["device_id"]
# Validate segment_id range
if not 1 <= segment_id <= 9:
raise ServiceValidationError(
f"segment_id must be between 1 and 9, got {segment_id}"
)
# Validate and convert batt_mode string to integer
valid_modes = {
"load_first": BATT_MODE_LOAD_FIRST,
"battery_first": BATT_MODE_BATTERY_FIRST,
@@ -117,121 +109,50 @@ def async_setup_services(hass: HomeAssistant) -> None:
)
batt_mode: int = valid_modes[batt_mode_str]
start_time = _parse_time_str(start_time_str, "start_time")
end_time = _parse_time_str(end_time_str, "end_time")
# Convert time strings to datetime.time objects
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
try:
# Take only HH:MM part (ignore seconds if present)
start_parts = start_time_str.split(":")
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"start_time must be in HH:MM or HH:MM:SS format"
) from err
try:
# Take only HH:MM part (ignore seconds if present)
end_parts = end_time_str.split(":")
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"end_time must be in HH:MM or HH:MM:SS format"
) from err
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
coordinator: GrowattCoordinator = _get_coordinator(hass, device_id, "min")
await coordinator.update_time_segment(
segment_id, batt_mode, start_time, end_time, enabled
segment_id,
batt_mode,
start_time,
end_time,
enabled,
)
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
"""Handle read_time_segments service call."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "min"
)
device_id: str = call.data["device_id"]
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
return {"time_segments": time_segments}
async def handle_write_ac_charge_times(call: ServiceCall) -> None:
"""Handle write_ac_charge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
# Read current settings first — the SPH API requires all 3 periods in
# every write call. Any period not supplied by the caller is filled in
# from the cache so existing settings are not overwritten with zeros.
current = await coordinator.read_ac_charge_times()
charge_power: int = int(call.data.get("charge_power", current["charge_power"]))
charge_stop_soc: int = int(
call.data.get("charge_stop_soc", current["charge_stop_soc"])
)
mains_enabled: bool = call.data.get("mains_enabled", current["mains_enabled"])
if not 0 <= charge_power <= 100:
raise ServiceValidationError(
f"charge_power must be between 0 and 100, got {charge_power}"
)
if not 0 <= charge_stop_soc <= 100:
raise ServiceValidationError(
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}"
)
periods = []
for i in range(1, 4):
cached = current["periods"][i - 1]
start = _parse_time_str(
call.data.get(f"period_{i}_start", cached["start_time"]),
f"period_{i}_start",
)
end = _parse_time_str(
call.data.get(f"period_{i}_end", cached["end_time"]),
f"period_{i}_end",
)
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
await coordinator.update_ac_charge_times(
charge_power, charge_stop_soc, mains_enabled, periods
)
async def handle_write_ac_discharge_times(call: ServiceCall) -> None:
"""Handle write_ac_discharge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
# Read current settings first — same read-merge-write pattern as charge.
current = await coordinator.read_ac_discharge_times()
discharge_power: int = int(
call.data.get("discharge_power", current["discharge_power"])
)
discharge_stop_soc: int = int(
call.data.get("discharge_stop_soc", current["discharge_stop_soc"])
)
if not 0 <= discharge_power <= 100:
raise ServiceValidationError(
f"discharge_power must be between 0 and 100, got {discharge_power}"
)
if not 0 <= discharge_stop_soc <= 100:
raise ServiceValidationError(
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}"
)
periods = []
for i in range(1, 4):
cached = current["periods"][i - 1]
start = _parse_time_str(
call.data.get(f"period_{i}_start", cached["start_time"]),
f"period_{i}_start",
)
end = _parse_time_str(
call.data.get(f"period_{i}_end", cached["end_time"]),
f"period_{i}_end",
)
enabled: bool = call.data.get(f"period_{i}_enabled", cached["enabled"])
periods.append({"start_time": start, "end_time": end, "enabled": enabled})
await coordinator.update_ac_discharge_times(
discharge_power, discharge_stop_soc, periods
)
async def handle_read_ac_charge_times(call: ServiceCall) -> dict[str, Any]:
"""Handle read_ac_charge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
return await coordinator.read_ac_charge_times()
async def handle_read_ac_discharge_times(call: ServiceCall) -> dict[str, Any]:
"""Handle read_ac_discharge_times service call for SPH devices."""
coordinator: GrowattCoordinator = _get_coordinator(
hass, call.data["device_id"], "sph"
)
return await coordinator.read_ac_discharge_times()
# Register services without schema - services.yaml will provide UI definition
# Schema validation happens in the handler functions
hass.services.async_register(
@@ -247,31 +168,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
handle_read_time_segments,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"write_ac_charge_times",
handle_write_ac_charge_times,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
"write_ac_discharge_times",
handle_write_ac_discharge_times,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
"read_ac_charge_times",
handle_read_ac_charge_times,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
"read_ac_discharge_times",
handle_read_ac_discharge_times,
supports_response=SupportsResponse.ONLY,
)

View File

@@ -48,162 +48,3 @@ read_time_segments:
selector:
device:
integration: growatt_server
write_ac_charge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server
charge_power:
required: false
example: 100
selector:
number:
min: 0
max: 100
mode: slider
charge_stop_soc:
required: false
example: 100
selector:
number:
min: 0
max: 100
mode: slider
mains_enabled:
required: false
example: true
selector:
boolean:
period_1_start:
required: false
example: "00:00"
selector:
time:
period_1_end:
required: false
example: "00:00"
selector:
time:
period_1_enabled:
required: false
example: false
selector:
boolean:
period_2_start:
required: false
example: "00:00"
selector:
time:
period_2_end:
required: false
example: "00:00"
selector:
time:
period_2_enabled:
required: false
example: false
selector:
boolean:
period_3_start:
required: false
example: "00:00"
selector:
time:
period_3_end:
required: false
example: "00:00"
selector:
time:
period_3_enabled:
required: false
example: false
selector:
boolean:
write_ac_discharge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server
discharge_power:
required: false
example: 100
selector:
number:
min: 0
max: 100
mode: slider
discharge_stop_soc:
required: false
example: 20
selector:
number:
min: 0
max: 100
mode: slider
period_1_start:
required: false
example: "00:00"
selector:
time:
period_1_end:
required: false
example: "00:00"
selector:
time:
period_1_enabled:
required: false
example: false
selector:
boolean:
period_2_start:
required: false
example: "00:00"
selector:
time:
period_2_end:
required: false
example: "00:00"
selector:
time:
period_2_enabled:
required: false
example: false
selector:
boolean:
period_3_start:
required: false
example: "00:00"
selector:
time:
period_3_end:
required: false
example: "00:00"
selector:
time:
period_3_enabled:
required: false
example: false
selector:
boolean:
read_ac_charge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server
read_ac_discharge_times:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server

View File

@@ -58,14 +58,14 @@
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
},
"description": "Token authentication is only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
},
"user": {
"description": "Note: Token authentication is currently only supported for MIN/SPH devices. For other device types, please use username/password authentication.",
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"menu_options": {
"password_auth": "Username/password",
"token_auth": "API token (MIN/SPH only)"
"token_auth": "API token (MIN/TLX only)"
},
"title": "Choose authentication method"
}
@@ -243,24 +243,6 @@
"mix_wattage_pv_all": {
"name": "All PV wattage"
},
"sph_grid_frequency": {
"name": "AC frequency"
},
"sph_temperature_1": {
"name": "Temperature 1"
},
"sph_temperature_2": {
"name": "Temperature 2"
},
"sph_temperature_3": {
"name": "Temperature 3"
},
"sph_temperature_4": {
"name": "Temperature 4"
},
"sph_temperature_5": {
"name": "Temperature 5"
},
"storage_ac_input_frequency_out": {
"name": "AC input frequency"
},
@@ -594,26 +576,6 @@
}
},
"services": {
"read_ac_charge_times": {
"description": "Read AC charge time periods from an SPH device.",
"fields": {
"device_id": {
"description": "The Growatt SPH device to read from.",
"name": "Device"
}
},
"name": "Read AC charge times"
},
"read_ac_discharge_times": {
"description": "Read AC discharge time periods from an SPH device.",
"fields": {
"device_id": {
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
}
},
"name": "Read AC discharge times"
},
"read_time_segments": {
"description": "Read all time segments from a supported inverter.",
"fields": {
@@ -653,118 +615,6 @@
}
},
"name": "Update time segment"
},
"write_ac_charge_times": {
"description": "Write AC charge time periods to an SPH device.",
"fields": {
"charge_power": {
"description": "Charge power limit (%).",
"name": "Charge power"
},
"charge_stop_soc": {
"description": "Stop charging at this state of charge (%).",
"name": "Charge stop SOC"
},
"device_id": {
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
},
"mains_enabled": {
"description": "Enable AC (mains) charging.",
"name": "Mains charging enabled"
},
"period_1_enabled": {
"description": "Enable time period 1.",
"name": "Period 1 enabled"
},
"period_1_end": {
"description": "End time for period 1 (HH:MM or HH:MM:SS).",
"name": "Period 1 end"
},
"period_1_start": {
"description": "Start time for period 1 (HH:MM or HH:MM:SS).",
"name": "Period 1 start"
},
"period_2_enabled": {
"description": "Enable time period 2.",
"name": "Period 2 enabled"
},
"period_2_end": {
"description": "End time for period 2 (HH:MM or HH:MM:SS).",
"name": "Period 2 end"
},
"period_2_start": {
"description": "Start time for period 2 (HH:MM or HH:MM:SS).",
"name": "Period 2 start"
},
"period_3_enabled": {
"description": "Enable time period 3.",
"name": "Period 3 enabled"
},
"period_3_end": {
"description": "End time for period 3 (HH:MM or HH:MM:SS).",
"name": "Period 3 end"
},
"period_3_start": {
"description": "Start time for period 3 (HH:MM or HH:MM:SS).",
"name": "Period 3 start"
}
},
"name": "Write AC charge times"
},
"write_ac_discharge_times": {
"description": "Write AC discharge time periods to an SPH device.",
"fields": {
"device_id": {
"description": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_ac_charge_times::fields::device_id::name%]"
},
"discharge_power": {
"description": "Discharge power limit (%).",
"name": "Discharge power"
},
"discharge_stop_soc": {
"description": "Stop discharging at this state of charge (%).",
"name": "Discharge stop SOC"
},
"period_1_enabled": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_enabled::name%]"
},
"period_1_end": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_end::name%]"
},
"period_1_start": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_1_start::name%]"
},
"period_2_enabled": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_enabled::name%]"
},
"period_2_end": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_end::name%]"
},
"period_2_start": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_2_start::name%]"
},
"period_3_enabled": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_enabled::name%]"
},
"period_3_end": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_end::name%]"
},
"period_3_start": {
"description": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::description%]",
"name": "[%key:component::growatt_server::services::write_ac_charge_times::fields::period_3_start::name%]"
}
},
"name": "Write AC discharge times"
}
},
"title": "Growatt Server"

View File

@@ -130,6 +130,7 @@ 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"
ISSUE_KEY_ADDON_DEPRECATED_ARCH = "issue_addon_deprecated_arch_addon"
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
@@ -170,6 +171,7 @@ EXTRA_PLACEHOLDERS = {
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
},
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
ISSUE_KEY_ADDON_DEPRECATED_ARCH: HELP_URLS,
}

View File

@@ -6,7 +6,6 @@ from typing import Any
from attr import asdict
from homeassistant.components.diagnostics import entity_entry_as_dict
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -45,9 +44,7 @@ async def async_get_config_entry_diagnostics(
state_dict = dict(state.as_dict())
state_dict.pop("context", None)
entities.append(
{"entry": entity_entry_as_dict(entity_entry), "state": state_dict}
)
entities.append({"entry": asdict(entity_entry), "state": state_dict})
devices.append({"device": asdict(device), "entities": entities})

View File

@@ -45,6 +45,7 @@ from .const import (
EVENT_SUPPORTED_CHANGED,
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
@@ -88,6 +89,7 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_system_disk_lifetime",
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
}
_LOGGER = logging.getLogger(__name__)

View File

@@ -19,6 +19,7 @@ from .const import (
EXTRA_PLACEHOLDERS,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_DEPRECATED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
@@ -231,6 +232,7 @@ async def async_create_fix_flow(
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
ISSUE_KEY_ADDON_BOOT_FAIL,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
}:
return AddonIssueRepairFlow(hass, issue_id)

View File

@@ -85,6 +85,19 @@
},
"title": "Installed app is deprecated"
},
"issue_addon_deprecated_arch_addon": {
"fix_flow": {
"abort": {
"apply_suggestion_fail": "Could not uninstall the app. Check the Supervisor logs for more details."
},
"step": {
"addon_execute_remove": {
"description": "App {addon} only supports architectures and/or machines which are no longer supported by Home Assistant. It will not be able to receive updates and will stop working in a future release.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
}
}
},
"title": "Installed app does not work on supported architectures and/or machines"
},
"issue_addon_detached_addon_missing": {
"description": "Repository for app {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [app's documentation]({addon_url}) for installation instructions and add the repository to the store.",
"title": "Missing repository for an installed app"

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from apyhiveapi import Auth
@@ -27,8 +26,6 @@ from homeassistant.core import callback
from . import HiveConfigEntry
from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN
_LOGGER = logging.getLogger(__name__)
class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Hive config flow."""
@@ -39,7 +36,7 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self.tokens: dict[str, Any] = {}
self.tokens: dict[str, str] = {}
self.device_registration: bool = False
self.device_name = "Home Assistant"
@@ -70,22 +67,11 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
except HiveApiError:
errors["base"] = "no_internet_available"
if (
auth_result := self.tokens.get("AuthenticationResult", {})
) and auth_result.get("NewDeviceMetadata"):
_LOGGER.debug("Login successful, New device detected")
self.device_registration = True
return await self.async_step_configuration()
if self.tokens.get("ChallengeName") == "SMS_MFA":
_LOGGER.debug("Login successful, SMS 2FA required")
# Complete SMS 2FA.
return await self.async_step_2fa()
if not errors:
_LOGGER.debug(
"Login successful, no new device detected, no 2FA required"
)
# Complete the entry.
try:
return await self.async_setup_hive_entry()
@@ -117,7 +103,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "no_internet_available"
if not errors:
_LOGGER.debug("2FA successful")
if self.source == SOURCE_REAUTH:
return await self.async_setup_hive_entry()
self.device_registration = True
@@ -134,11 +119,10 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input:
if self.device_registration:
_LOGGER.debug("Attempting to register device")
self.device_name = user_input["device_name"]
await self.hive_auth.device_registration(user_input["device_name"])
self.data["device_data"] = await self.hive_auth.get_device_data()
_LOGGER.debug("Device registration successful")
try:
return await self.async_setup_hive_entry()
except UnknownHiveError:
@@ -158,7 +142,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
raise UnknownHiveError
# Setup the config entry
_LOGGER.debug("Setting up Hive entry")
self.data["tokens"] = self.tokens
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
@@ -177,7 +160,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_USERNAME: entry_data[CONF_USERNAME],
CONF_PASSWORD: entry_data[CONF_PASSWORD],
}
_LOGGER.debug("Reauthenticating user")
return await self.async_step_user(data)
@staticmethod

View File

@@ -38,7 +38,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.FAN,
Platform.LIGHT,
Platform.NUMBER,

View File

@@ -1,325 +0,0 @@
"""Provides climate entities for Home Connect."""
import logging
from typing import Any, cast
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import Execution
from homeassistant.components.climate import (
FAN_AUTO,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
HVAC_MODES_PROGRAMS_MAP = {
HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
}
PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()}
PRESET_MODES_PROGRAMS_MAP = {
"active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN,
}
PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()}
FAN_MODES_OPTIONS = {
FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
}
FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()}
AIR_CONDITIONER_ENTITY_DESCRIPTION = ClimateEntityDescription(
key="air_conditioner",
translation_key="air_conditioner",
name=None,
)
def _get_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return (
[HomeConnectAirConditioningEntity(appliance_coordinator)]
if (programs := appliance_coordinator.data.programs)
and any(
program.key in PROGRAMS_HVAC_MODES_MAP
and (
program.constraints is None
or program.constraints.execution
in (Execution.SELECT_AND_START, Execution.START_ONLY)
)
for program in programs
)
else []
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect climate entities."""
setup_home_connect_entry(
hass,
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
"""Representation of a Home Connect climate entity."""
# Note: The base class requires this to be set even though this
# class doesn't support any temperature related functionality.
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(
self,
coordinator: HomeConnectApplianceCoordinator,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
AIR_CONDITIONER_ENTITY_DESCRIPTION,
context_override=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available hvac operation modes."""
hvac_modes = [
hvac_mode
for program in self.appliance.programs
if (hvac_mode := PROGRAMS_HVAC_MODES_MAP.get(program.key))
and (
program.constraints is None
or program.constraints.execution
in (Execution.SELECT_AND_START, Execution.START_ONLY)
)
]
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
hvac_modes.append(HVACMode.OFF)
return hvac_modes
@property
def preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes."""
return (
[
PROGRAMS_PRESET_MODES_MAP[
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
]
]
if any(
program.key
is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
for program in self.appliance.programs
)
else None
)
@property
def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features."""
features = ClimateEntityFeature(0)
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
if self.preset_modes:
features |= ClimateEntityFeature.PRESET_MODE
if self.appliance.options.get(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
):
features |= ClimateEntityFeature.FAN_MODE
return features
@callback
def _handle_coordinator_update_fan_mode(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()
_LOGGER.debug(
"Updated %s (fan mode), new state: %s", self.entity_id, self.fan_mode
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(
self.async_write_ha_state,
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
)
)
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update_fan_mode,
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
)
)
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update,
EventKey(SettingKey.BSH_COMMON_POWER_STATE),
)
)
def update_native_value(self) -> None:
"""Set the HVAC Mode and preset mode values."""
event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
program_key = cast(ProgramKey, event.value) if event else None
power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE)
self._attr_hvac_mode = (
HVACMode.OFF
if power_state is not None and power_state.value != BSH_POWER_ON
else PROGRAMS_HVAC_MODES_MAP.get(program_key)
if program_key
and program_key
!= ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
else None
)
self._attr_preset_mode = (
PROGRAMS_PRESET_MODES_MAP.get(program_key)
if program_key
== ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
else None
)
@property
def fan_mode(self) -> str | None:
"""Return the fan setting."""
option_value = None
if event := self.appliance.events.get(
EventKey(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
)
):
option_value = event.value
return (
FAN_MODES_OPTIONS_INVERTED.get(cast(str, option_value))
if option_value is not None
else None
)
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes."""
if (
(
option_definition := self.appliance.options.get(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
)
)
and (option_constraints := option_definition.constraints)
and option_constraints.allowed_values
):
return [
fan_mode
for fan_mode, api_value in FAN_MODES_OPTIONS.items()
if api_value in option_constraints.allowed_values
]
if option_definition:
# Then the constraints or the allowed values are not present
# So we stick to the default values
return list(FAN_MODES_OPTIONS.keys())
return None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Switch the device on."""
try:
await self.coordinator.client.set_setting(
self.appliance.info.ha_id,
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
value=BSH_POWER_ON,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_on",
translation_placeholders={
**get_dict_from_home_connect_error(err),
"appliance_name": self.appliance.info.name,
"value": BSH_POWER_ON,
},
) from err
async def async_turn_off(self, **kwargs: Any) -> None:
"""Switch the device off."""
try:
await self.coordinator.client.set_setting(
self.appliance.info.ha_id,
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
value=BSH_POWER_STANDBY,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="power_off",
translation_placeholders={
**get_dict_from_home_connect_error(err),
"appliance_name": self.appliance.info.name,
"value": BSH_POWER_STANDBY,
},
) from err
async def _set_program(self, program_key: ProgramKey) -> None:
try:
await self.coordinator.client.start_program(
self.appliance.info.ha_id, program_key=program_key
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program",
translation_placeholders={
**get_dict_from_home_connect_error(err),
"program": program_key.value,
},
) from err
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode is HVACMode.OFF:
await self.async_turn_off()
else:
await self._set_program(HVAC_MODES_PROGRAMS_MAP[hvac_mode])
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode])
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
await super().async_set_option_with_key(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
FAN_MODES_OPTIONS[fan_mode],
)
_LOGGER.debug(
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
)

View File

@@ -63,7 +63,6 @@ BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open"
SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
SERVICE_SETTING = "change_setting"
SERVICE_START_SELECTED_PROGRAM = "start_selected_program"
ATTR_AFFECTS_TO = "affects_to"
ATTR_KEY = "key"

View File

@@ -79,29 +79,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
"""
return self.appliance.info.connected and self._attr_available
async def async_set_option_with_key(
self, option_key: OptionKey, value: Any
) -> None:
"""Set an option for the entity."""
try:
# We try to set the active program option first,
# if it fails we try to set the selected program option
with contextlib.suppress(ActiveProgramNotSetError):
await self.coordinator.client.set_active_program_option(
self.appliance.info.ha_id, option_key=option_key, value=value
)
return
await self.coordinator.client.set_selected_program_option(
self.appliance.info.ha_id, option_key=option_key, value=value
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_option",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
class HomeConnectOptionEntity(HomeConnectEntity):
"""Class for entities that represents program options."""
@@ -118,9 +95,40 @@ class HomeConnectOptionEntity(HomeConnectEntity):
return event.value
return None
async def async_set_option(self, value: Any) -> None:
async def async_set_option(self, value: str | float | bool) -> None:
"""Set an option for the entity."""
await super().async_set_option_with_key(self.bsh_key, value)
try:
# We try to set the active program option first,
# if it fails we try to set the selected program option
with contextlib.suppress(ActiveProgramNotSetError):
await self.coordinator.client.set_active_program_option(
self.appliance.info.ha_id,
option_key=self.bsh_key,
value=value,
)
_LOGGER.debug(
"Updated %s for the active program, new state: %s",
self.entity_id,
self.state,
)
return
await self.coordinator.client.set_selected_program_option(
self.appliance.info.ha_id,
option_key=self.bsh_key,
value=value,
)
_LOGGER.debug(
"Updated %s for the selected program, new state: %s",
self.entity_id,
self.state,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_option",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
@property
def bsh_key(self) -> OptionKey:

View File

@@ -1,9 +1,11 @@
"""Provides fan entities for Home Connect."""
import contextlib
import logging
from typing import cast
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from homeassistant.components.fan import (
FanEntity,
@@ -11,11 +13,14 @@ from homeassistant.components.fan import (
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import DOMAIN
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
@@ -171,7 +176,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await super().async_set_option_with_key(
await self._async_set_option(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
percentage,
)
@@ -183,14 +188,41 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target fan mode."""
await super().async_set_option_with_key(
await self._async_set_option(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
FAN_SPEED_MODE_OPTIONS[preset_mode],
)
_LOGGER.debug(
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
"Updated %s's speed mode option, new state: %s",
self.entity_id,
self.state,
)
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
"""Set an option for the entity."""
try:
# We try to set the active program option first,
# if it fails we try to set the selected program option
with contextlib.suppress(ActiveProgramNotSetError):
await self.coordinator.client.set_active_program_option(
self.appliance.info.ha_id,
option_key=key,
value=value,
)
return
await self.coordinator.client.set_selected_program_option(
self.appliance.info.ha_id,
option_key=key,
value=value,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_option",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
@property
def available(self) -> bool:
"""Return True if entity is available."""

View File

@@ -245,10 +245,25 @@
"change_setting": {
"service": "mdi:cog"
},
"pause_program": {
"service": "mdi:pause"
},
"resume_program": {
"service": "mdi:play-pause"
},
"select_program": {
"service": "mdi:form-select"
},
"set_option_active": {
"service": "mdi:gesture-tap"
},
"set_option_selected": {
"service": "mdi:gesture-tap"
},
"set_program_and_options": {
"service": "mdi:form-select"
},
"start_selected_program": {
"start_program": {
"service": "mdi:play"
}
}

View File

@@ -23,6 +23,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.32.0"],
"requirements": ["aiohomeconnect==0.30.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -13,7 +13,7 @@ from aiohomeconnect.model import (
ProgramKey,
SettingKey,
)
from aiohomeconnect.model.error import HomeConnectError, NoProgramActiveError
from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID
@@ -32,7 +32,6 @@ from .const import (
PROGRAM_ENUM_OPTIONS,
SERVICE_SET_PROGRAM_AND_OPTIONS,
SERVICE_SETTING,
SERVICE_START_SELECTED_PROGRAM,
TRANSLATION_KEYS_PROGRAMS_MAP,
)
from .coordinator import HomeConnectConfigEntry
@@ -125,23 +124,7 @@ SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All(
_require_program_or_at_least_one_option,
)
SERVICE_START_SELECTED_PROGRAM_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
}
).extend(
{
vol.Optional(translation_key): schema
for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
if key
in (
OptionKey.BSH_COMMON_START_IN_RELATIVE,
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
)
}
)
)
SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
async def _get_client_and_ha_id(
@@ -279,50 +262,6 @@ async def async_service_set_program_and_options(call: ServiceCall) -> None:
) from err
async def async_service_start_selected_program(call: ServiceCall) -> None:
"""Service to start a program that is already selected."""
data = dict(call.data)
client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID))
try:
try:
program_obj = await client.get_active_program(ha_id)
except NoProgramActiveError:
program_obj = await client.get_selected_program(ha_id)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="fetch_program_error",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
if not program_obj.key:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_program_to_start",
)
program = program_obj.key
options_dict = {option.key: option for option in program_obj.options or []}
for option, value in data.items():
option_key = PROGRAM_OPTIONS[option][0]
options_dict[option_key] = Option(option_key, value)
try:
await client.start_program(
ha_id,
program_key=program,
options=list(options_dict.values()) if options_dict else None,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="start_program",
translation_placeholders={
"program": program,
**get_dict_from_home_connect_error(err),
},
) from err
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register custom actions."""
@@ -336,9 +275,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
async_service_set_program_and_options,
schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_START_SELECTED_PROGRAM,
async_service_start_selected_program,
schema=SERVICE_START_SELECTED_PROGRAM_SCHEMA,
)

View File

@@ -127,7 +127,6 @@ set_program_and_options:
- cooking_oven_program_heating_mode_top_bottom_heating
- cooking_oven_program_heating_mode_top_bottom_heating_eco
- cooking_oven_program_heating_mode_bottom_heating
- cooking_oven_program_heating_mode_bread_baking
- cooking_oven_program_heating_mode_pizza_setting
- cooking_oven_program_heating_mode_slow_cook
- cooking_oven_program_heating_mode_intensive_heat
@@ -136,7 +135,6 @@ set_program_and_options:
- cooking_oven_program_heating_mode_frozen_heatup_special
- cooking_oven_program_heating_mode_desiccation
- cooking_oven_program_heating_mode_defrost
- cooking_oven_program_heating_mode_dough_proving
- cooking_oven_program_heating_mode_proof
- cooking_oven_program_heating_mode_hot_air_30_steam
- cooking_oven_program_heating_mode_hot_air_60_steam
@@ -680,29 +678,3 @@ change_setting:
required: true
selector:
object:
start_selected_program:
fields:
device_id:
required: true
selector:
device:
integration: home_connect
b_s_h_common_option_finish_in_relative:
example: 3600
required: false
selector:
number:
min: 0
step: 1
mode: box
unit_of_measurement: s
b_s_h_common_option_start_in_relative:
example: 3600
required: false
selector:
number:
min: 0
step: 1
mode: box
unit_of_measurement: s

View File

@@ -119,23 +119,6 @@
"name": "Stop program"
}
},
"climate": {
"air_conditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]"
}
},
"preset_mode": {
"state": {
"active_clean": "Active clean"
}
}
}
}
},
"fan": {
"air_conditioner": {
"state_attributes": {
@@ -261,10 +244,8 @@
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
@@ -617,10 +598,8 @@
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
@@ -1344,12 +1323,6 @@
"fetch_api_error": {
"message": "Error obtaining data from the API: {error}"
},
"fetch_program_error": {
"message": "Error obtaining the selected or active program: {error}"
},
"no_program_to_start": {
"message": "No program to start"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
@@ -1622,10 +1595,8 @@
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
"cooking_common_program_hood_venting": "Venting",
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
"cooking_oven_program_heating_mode_defrost": "Defrost",
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
"cooking_oven_program_heating_mode_dough_proving": "Dough proving",
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
"cooking_oven_program_heating_mode_hot_air": "Hot air",
"cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
@@ -2084,24 +2055,6 @@
"name": "Washer options"
}
}
},
"start_selected_program": {
"description": "Starts the already selected program. You can update start-only options to start the program with them or modify them on a program that is already active with a delayed start.",
"fields": {
"b_s_h_common_option_finish_in_relative": {
"description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::description%]",
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]"
},
"b_s_h_common_option_start_in_relative": {
"description": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::description%]",
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]"
},
"device_id": {
"description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]",
"name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]"
}
},
"name": "Start selected program"
}
}
}

View File

@@ -11,7 +11,6 @@
"requirements": [
"HAP-python==5.0.0",
"fnv-hash-fast==2.0.0",
"homekit-audio-proxy==1.2.1",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -6,7 +6,6 @@ import logging
from typing import Any
from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg
from homekit_audio_proxy import AudioProxy
from pyhap.camera import (
VIDEO_CODEC_PARAM_LEVEL_TYPES,
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES,
@@ -90,10 +89,11 @@ AUDIO_OUTPUT = (
"{a_application}"
"-ac 1 -ar {a_sample_rate}k "
"-b:a {a_max_bitrate}k -bufsize {a_bufsize}k "
"{a_frame_duration}"
"-payload_type 110 "
"-ssrc {a_ssrc} -f rtp "
"rtp://127.0.0.1:{a_proxy_port}?pkt_size={a_pkt_size}"
"-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} "
"srtp://{address}:{a_port}?rtcpport={a_port}&"
"localrtpport={a_port}&pkt_size={a_pkt_size}"
)
SLOW_RESOLUTIONS = [
@@ -120,7 +120,6 @@ FFMPEG_WATCH_INTERVAL = timedelta(seconds=5)
FFMPEG_LOGGER = "ffmpeg_logger"
FFMPEG_WATCHER = "ffmpeg_watcher"
FFMPEG_PID = "ffmpeg_pid"
AUDIO_PROXY = "audio_proxy"
SESSION_ID = "session_id"
CONFIG_DEFAULTS = {
@@ -340,33 +339,8 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
+ " "
)
audio_application = ""
audio_frame_duration = ""
if self.config[CONF_AUDIO_CODEC] == "libopus":
audio_application = "-application lowdelay "
audio_frame_duration = (
f"-frame_duration {stream_config.get('a_packet_time', 20)} "
)
# Start audio proxy to convert Opus RTP timestamps from 48kHz
# (FFmpeg's hardcoded Opus RTP clock rate per RFC 7587) to the
# sample rate negotiated by HomeKit (typically 16kHz).
# a_sample_rate is in kHz (e.g. 16 for 16000 Hz) from pyhap TLV.
audio_proxy: AudioProxy | None = None
if self.config[CONF_SUPPORT_AUDIO]:
audio_proxy = AudioProxy(
dest_addr=stream_config["address"],
dest_port=stream_config["a_port"],
srtp_key_b64=stream_config["a_srtp_key"],
target_clock_rate=stream_config["a_sample_rate"] * 1000,
)
await audio_proxy.async_start()
if not audio_proxy.local_port:
_LOGGER.error(
"[%s] Audio proxy failed to start",
self.display_name,
)
await audio_proxy.async_stop()
audio_proxy = None
output_vars = stream_config.copy()
output_vars.update(
{
@@ -380,8 +354,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
"a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE],
"a_encoder": self.config[CONF_AUDIO_CODEC],
"a_application": audio_application,
"a_frame_duration": audio_frame_duration,
"a_proxy_port": audio_proxy.local_port if audio_proxy else 0,
}
)
output = VIDEO_OUTPUT.format(**output_vars)
@@ -399,8 +371,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
)
if not opened:
_LOGGER.error("Failed to open ffmpeg stream")
if audio_proxy:
await audio_proxy.async_stop()
return False
_LOGGER.debug(
@@ -411,7 +381,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
session_info["stream"] = stream
session_info[FFMPEG_PID] = stream.process.pid
session_info[AUDIO_PROXY] = audio_proxy
stderr_reader = await stream.get_reader(source=FFMPEG_STDERR)
@@ -472,9 +441,6 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
async def stop_stream(self, session_info: dict[str, Any]) -> None:
"""Stop the stream for the given ``session_id``."""
session_id = session_info["id"]
if proxy := session_info.pop(AUDIO_PROXY, None):
await proxy.async_stop()
if not (stream := session_info.get("stream")):
_LOGGER.debug("No stream for session ID %s", session_id)
return

View File

@@ -11,14 +11,10 @@ from homematicip.base.enums import (
OpticalSignalBehaviour,
RGBColorState,
)
from homematicip.base.functionalChannels import (
NotificationLightChannel,
NotificationMp3SoundChannel,
)
from homematicip.base.functionalChannels import NotificationLightChannel
from homematicip.device import (
BrandDimmer,
BrandSwitchNotificationLight,
CombinationSignallingDevice,
Device,
Dimmer,
DinRailDimmer3,
@@ -112,8 +108,6 @@ async def async_setup_entry(
entities.append(
HomematicipOpticalSignalLight(hap, device, ch.index, led_number)
)
elif isinstance(device, CombinationSignallingDevice):
entities.append(HomematicipCombinationSignallingLight(hap, device))
async_add_entities(entities)
@@ -592,70 +586,3 @@ class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity):
rgb=simple_rgb_color,
dimLevel=0.0,
)
class HomematicipCombinationSignallingLight(HomematicipGenericEntity, LightEntity):
"""Representation of the HomematicIP combination signalling device light (HmIP-MP3P)."""
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
_color_switcher: dict[str, tuple[float, float]] = {
RGBColorState.WHITE: (0.0, 0.0),
RGBColorState.RED: (0.0, 100.0),
RGBColorState.YELLOW: (60.0, 100.0),
RGBColorState.GREEN: (120.0, 100.0),
RGBColorState.TURQUOISE: (180.0, 100.0),
RGBColorState.BLUE: (240.0, 100.0),
RGBColorState.PURPLE: (300.0, 100.0),
}
def __init__(
self, hap: HomematicipHAP, device: CombinationSignallingDevice
) -> None:
"""Initialize the combination signalling light entity."""
super().__init__(hap, device, channel=1, is_multi_channel=False)
@property
def _func_channel(self) -> NotificationMp3SoundChannel:
return self._device.functionalChannels[self._channel]
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._func_channel.on
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return int((self._func_channel.dimLevel or 0.0) * 255)
@property
def hs_color(self) -> tuple[float, float]:
"""Return the hue and saturation color value [float, float]."""
simple_rgb_color = self._func_channel.simpleRGBColorState
return self._color_switcher.get(simple_rgb_color, (0.0, 0.0))
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
simple_rgb_color = _convert_color(hs_color)
brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
# Default to full brightness when no kwargs given
if not kwargs:
brightness = 255
# Minimum brightness is 10, otherwise the LED is disabled
brightness = max(10, brightness)
dim_level = brightness / 255.0
await self._func_channel.set_rgb_dim_level_async(
rgb_color_state=simple_rgb_color.name,
dim_level=dim_level,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._func_channel.turn_off_async()

View File

@@ -2,19 +2,22 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from homeassistant.helpers.condition import (
Condition,
make_entity_state_attribute_condition,
make_entity_state_condition,
)
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
"is_drying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
"is_drying": make_entity_state_attribute_condition(
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
),
"is_humidifying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
"is_humidifying": make_entity_state_attribute_condition(
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
),
}

View File

@@ -2,17 +2,20 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from homeassistant.helpers.trigger import (
Trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
)
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
),
"started_humidifying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
"started_humidifying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),

View File

@@ -18,9 +18,9 @@ from homeassistant.components.weather import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
@@ -38,11 +38,24 @@ HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
),
}
class HumidityChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for humidity value changes across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
class HumidityCrossedThresholdTrigger(
EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(HUMIDITY_DOMAIN_SPECS),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS
),
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
}

View File

@@ -7,7 +7,7 @@ from typing import Any
import attr
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -94,7 +94,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str,
state_dict = dict(state.as_dict())
state_dict.pop("context", None)
entity = entity_entry_as_dict(entity_entry)
entity = attr.asdict(entity_entry)
entity["state"] = state_dict
entities.append(entity)

View File

@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/huum",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["huum==0.8.1"]
}

View File

@@ -1,105 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: done
comment: |
PLANNED: Rename result2 -> result throughout test_config_flow.py.
config-flow:
status: done
comment: |
PLANNED: Move _async_abort_entries_match before the try block so duplicate
entries are rejected before credentials are validated. Remove _LOGGER.error
call from config_flow.py — the error message is redundant with the errors
dict entry.
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry:
status: done
comment: |
PLANNED: Move _async_abort_entries_match before the try block in
config_flow.py so duplicate entries are rejected before credentials are
validated.
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: |
PLANNED: Remove _LOGGER.error from coordinator.py — the message is already
passed to UpdateFailed, so logging it separately is redundant.
parallel-updates: todo
reauthentication-flow: todo
test-coverage:
status: todo
comment: |
PLANNED: Remove unique_id from mock config entry in conftest.py. Use
freezer-based time advancement instead of directly calling async_refresh().
Rename result2 -> result in test files.
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Single device per account, no dynamic devices.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: All entities are core functionality.
entity-translations: done
exception-translations: todo
icon-translations:
status: done
comment: |
PLANNED: Remove the icon property from climate.py — entities should not set
custom icons. Use HA defaults or icon translations instead.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Integration has no repair scenarios.
stale-devices:
status: exempt
comment: Single device per config entry.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -6,9 +6,9 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
@@ -28,13 +28,24 @@ BRIGHTNESS_DOMAIN_SPECS = {
),
}
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
class BrightnessCrossedThresholdTrigger(
EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for brightness crossed threshold."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
TRIGGERS: dict[str, type[Trigger]] = {
"brightness_changed": make_entity_numerical_state_changed_trigger(
BRIGHTNESS_DOMAIN_SPECS
),
"brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BRIGHTNESS_DOMAIN_SPECS
),
"brightness_changed": BrightnessChangedTrigger,
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -78,27 +78,15 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot binary sensors using config entry."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotBinarySensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
async_add_entities(
LitterRobotBinarySensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
class LitterRobotBinarySensorEntity(

View File

@@ -58,26 +58,14 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotButtonEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, description in ROBOT_BUTTON_MAP.items()
if isinstance(robot, robot_type)
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
async_add_entities(
LitterRobotButtonEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, description in ROBOT_BUTTON_MAP.items()
if isinstance(robot, robot_type)
)
class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity):

View File

@@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -44,22 +43,11 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
)
self.account = Account(websession=async_get_clientsession(hass))
self.previous_members: set[str] = set()
# Initialize previous_members from the device registry so that
# stale devices can be detected on the first update after restart.
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
):
for domain, identifier in device.identifiers:
if domain == DOMAIN:
self.previous_members.add(identifier)
async def _async_update_data(self) -> None:
"""Update all device states from the Litter-Robot API."""
try:
await self.account.load_robots(subscribe_for_updates=True)
await self.account.refresh_robots()
await self.account.load_pets()
for pet in self.account.pets:
# Need to fetch weight history for `get_visits_since`
@@ -75,22 +63,6 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
translation_placeholders={"error": str(ex)},
) from ex
current_members = {robot.serial for robot in self.account.robots} | {
pet.id for pet in self.account.pets
}
if stale_members := self.previous_members - current_members:
device_registry = dr.async_get(self.hass)
for device_id in stale_members:
device = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_members = current_members
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:

View File

@@ -52,7 +52,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -64,7 +64,12 @@ rules:
status: done
comment: |
This integration doesn't have any cases where raising an issue is needed
stale-devices: done
stale-devices:
status: todo
comment: |
Currently handled via async_remove_config_entry_device,
but we should be able to remove devices automatically
# Platinum
async-dependency: done
inject-websession: done

View File

@@ -120,27 +120,15 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot selects using config entry."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotSelectEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, descriptions in ROBOT_SELECT_MAP.items()
if isinstance(robot, robot_type)
for description in descriptions
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
async_add_entities(
LitterRobotSelectEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, descriptions in ROBOT_SELECT_MAP.items()
if isinstance(robot, robot_type)
for description in descriptions
)
class LitterRobotSelectEntity(

View File

@@ -232,47 +232,23 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot sensors using config entry."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
known_pets: set[str] = set()
def _check_robots_and_pets() -> None:
entities: list[LitterRobotSensorEntity] = []
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
entities.extend(
LitterRobotSensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
all_pets = coordinator.account.pets
current_pets = {pet.id for pet in all_pets}
new_pets = current_pets - known_pets
if new_pets:
known_pets.update(new_pets)
entities.extend(
LitterRobotSensorEntity(
robot=pet, coordinator=coordinator, description=description
)
for pet in all_pets
if pet.id in new_pets
for description in PET_SENSORS
)
if entities:
async_add_entities(entities)
_check_robots_and_pets()
entry.async_on_unload(coordinator.async_add_listener(_check_robots_and_pets))
entities: list[LitterRobotSensorEntity] = [
LitterRobotSensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
]
entities.extend(
LitterRobotSensorEntity(
robot=pet, coordinator=coordinator, description=description
)
for pet in coordinator.account.pets
for description in PET_SENSORS
)
async_add_entities(entities)
class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity):

View File

@@ -66,27 +66,13 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot switches using config entry."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
RobotSwitchEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, entity_descriptions in SWITCH_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
async_add_entities(
RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
for robot in coordinator.account.robots
for robot_type, entity_descriptions in SWITCH_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):

View File

@@ -55,27 +55,15 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = list(coordinator.litter_robots())
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotTimeEntity(
robot=robot,
coordinator=coordinator,
description=LITTER_ROBOT_3_SLEEP_START,
)
for robot in all_robots
if robot.serial in new_robots
if isinstance(robot, LitterRobot3)
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
async_add_entities(
LitterRobotTimeEntity(
robot=robot,
coordinator=coordinator,
description=LITTER_ROBOT_3_SLEEP_START,
)
for robot in coordinator.litter_robots()
if isinstance(robot, LitterRobot3)
)
class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity):

View File

@@ -39,28 +39,14 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot update platform."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = list(coordinator.litter_robots())
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
entities = (
RobotUpdateEntity(
robot=robot,
coordinator=coordinator,
description=FIRMWARE_UPDATE_ENTITY,
)
for robot in all_robots
if robot.serial in new_robots
if isinstance(robot, LitterRobot4)
)
async_add_entities(entities, True)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
entities = (
RobotUpdateEntity(
robot=robot, coordinator=coordinator, description=FIRMWARE_UPDATE_ENTITY
)
for robot in coordinator.litter_robots()
if isinstance(robot, LitterRobot4)
)
async_add_entities(entities, True)
class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):

View File

@@ -48,24 +48,12 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = list(coordinator.litter_robots())
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotCleaner(
robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY
)
for robot in all_robots
if robot.serial in new_robots
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
async_add_entities(
LitterRobotCleaner(
robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY
)
for robot in coordinator.litter_robots()
)
class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):

View File

@@ -1,78 +0,0 @@
"""The LoJack integration for Home Assistant."""
from __future__ import annotations
from dataclasses import dataclass, field
from lojack_api import ApiError, AuthenticationError, LoJackClient, Vehicle
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LoJackCoordinator
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
@dataclass
class LoJackData:
"""Runtime data for a LoJack config entry."""
client: LoJackClient
coordinators: list[LoJackCoordinator] = field(default_factory=list)
type LoJackConfigEntry = ConfigEntry[LoJackData]
async def async_setup_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool:
"""Set up LoJack from a config entry."""
session = async_get_clientsession(hass)
try:
client = await LoJackClient.create(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
session=session,
)
except AuthenticationError as err:
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except ApiError as err:
raise ConfigEntryNotReady(f"API error during setup: {err}") from err
try:
vehicles = await client.list_devices()
except AuthenticationError as err:
await client.close()
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except ApiError as err:
await client.close()
raise ConfigEntryNotReady(f"API error during setup: {err}") from err
data = LoJackData(client=client)
entry.runtime_data = data
try:
for vehicle in vehicles or []:
if isinstance(vehicle, Vehicle):
coordinator = LoJackCoordinator(hass, client, entry, vehicle)
await coordinator.async_config_entry_first_refresh()
data.coordinators.append(coordinator)
except Exception:
await client.close()
raise
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LoJackConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.client.close()
return unload_ok

View File

@@ -1,111 +0,0 @@
"""Config flow for LoJack integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from lojack_api import ApiError, AuthenticationError, LoJackClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class LoJackConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LoJack."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
async with await LoJackClient.create(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
) as client:
user_id = client.user_id
except AuthenticationError:
errors["base"] = "invalid_auth"
except ApiError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if not user_id:
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"LoJack ({user_input[CONF_USERNAME]})",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
async with await LoJackClient.create(
reauth_entry.data[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
):
pass
except AuthenticationError:
errors["base"] = "invalid_auth"
except ApiError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
errors=errors,
)

View File

@@ -1,13 +0,0 @@
"""Constants for the LoJack integration."""
from __future__ import annotations
import logging
from typing import Final
DOMAIN: Final = "lojack"
LOGGER = logging.getLogger(__package__)
# Default polling interval (in minutes)
DEFAULT_UPDATE_INTERVAL: Final = 5

View File

@@ -1,68 +0,0 @@
"""Data update coordinator for the LoJack integration."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from lojack_api import ApiError, AuthenticationError, LoJackClient
from lojack_api.device import Vehicle
from lojack_api.models import Location
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER
if TYPE_CHECKING:
from . import LoJackConfigEntry
def get_device_name(vehicle: Vehicle) -> str:
"""Get a human-readable name for a vehicle."""
parts = [
str(vehicle.year) if vehicle.year else None,
vehicle.make,
vehicle.model,
]
name = " ".join(p for p in parts if p)
return name or vehicle.name or "Vehicle"
class LoJackCoordinator(DataUpdateCoordinator[Location]):
"""Class to manage fetching LoJack data for a single vehicle."""
config_entry: LoJackConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: LoJackClient,
entry: ConfigEntry,
vehicle: Vehicle,
) -> None:
"""Initialize the coordinator."""
self.client = client
self.vehicle = vehicle
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN}_{vehicle.id}",
update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
config_entry=entry,
)
async def _async_update_data(self) -> Location:
"""Fetch location data for this vehicle."""
try:
location = await self.vehicle.get_location(force=True)
except AuthenticationError as err:
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except ApiError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
if location is None:
raise UpdateFailed("No location data available")
return location

View File

@@ -1,78 +0,0 @@
"""Device tracker platform for LoJack integration."""
from __future__ import annotations
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import LoJackConfigEntry
from .const import DOMAIN
from .coordinator import LoJackCoordinator, get_device_name
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: LoJackConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LoJack device tracker from a config entry."""
async_add_entities(
LoJackDeviceTracker(coordinator)
for coordinator in entry.runtime_data.coordinators
)
class LoJackDeviceTracker(CoordinatorEntity[LoJackCoordinator], TrackerEntity):
"""Representation of a LoJack device tracker."""
_attr_has_entity_name = True
_attr_name = None # Main entity of the device, uses device name directly
def __init__(self, coordinator: LoJackCoordinator) -> None:
"""Initialize the device tracker."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.vehicle.id
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.vehicle.id)},
name=get_device_name(self.coordinator.vehicle),
manufacturer="Spireon LoJack",
model=self.coordinator.vehicle.model,
serial_number=self.coordinator.vehicle.vin,
)
@property
def source_type(self) -> SourceType:
"""Return the source type of the device."""
return SourceType.GPS
@property
def latitude(self) -> float | None:
"""Return the latitude of the device."""
return self.coordinator.data.latitude
@property
def longitude(self) -> float | None:
"""Return the longitude of the device."""
return self.coordinator.data.longitude
@property
def location_accuracy(self) -> int:
"""Return the location accuracy of the device."""
if self.coordinator.data.accuracy is not None:
return int(self.coordinator.data.accuracy)
return 0
@property
def battery_level(self) -> int | None:
"""Return the battery level of the device (if applicable)."""
# LoJack devices report vehicle battery voltage, not percentage
return None

View File

@@ -1,12 +0,0 @@
{
"domain": "lojack",
"name": "LoJack",
"codeowners": ["@devinslick"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lojack",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["lojack_api"],
"quality_scale": "silver",
"requirements": ["lojack-api==0.7.1"]
}

View File

@@ -1,81 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide 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: This integration does not provide actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not provide an options flow.
docs-installation-parameters:
status: done
comment: Documented in https://github.com/home-assistant/home-assistant.io/pull/43463
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery:
status: exempt
comment: This is a cloud polling integration with no local discovery mechanism since the devices are not on a local network.
discovery-update-info:
status: exempt
comment: This is a cloud polling integration with no local discovery mechanism.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Vehicles are tied to the user account. Changes require integration reload.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: The device tracker entity is the primary entity and should be enabled by default.
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No user-actionable repair scenarios identified for this integration.
stale-devices:
status: exempt
comment: Vehicles removed from the LoJack account stop appearing in API responses and become unavailable.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,38 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::lojack::config::step::user::data_description::password%]"
},
"description": "Re-enter the password for {username}."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your LoJack/Spireon account password",
"username": "Your LoJack/Spireon account email address"
},
"description": "Enter your LoJack/Spireon account credentials."
}
}
}
}

View File

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pylutron"],
"requirements": ["pylutron==0.4.0"],
"requirements": ["pylutron==0.3.0"],
"single_config_entry": true
}

View File

@@ -87,11 +87,11 @@ class LutronLed(LutronKeypad, SwitchEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the LED on."""
self._lutron_device.state = Led.LED_ON
self._lutron_device.state = True
def turn_off(self, **kwargs: Any) -> None:
"""Turn the LED off."""
self._lutron_device.state = Led.LED_OFF
self._lutron_device.state = False
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
@@ -108,4 +108,4 @@ class LutronLed(LutronKeypad, SwitchEntity):
def _update_attrs(self) -> None:
"""Update the state attributes."""
self._attr_is_on = self._lutron_device.last_state != Led.LED_OFF
self._attr_is_on = self._lutron_device.last_state

View File

@@ -80,7 +80,6 @@ class MatterUpdate(MatterEntity, UpdateEntity):
# Matter server.
_attr_should_poll = True
_software_update: MatterSoftwareVersion | None = None
_installed_software_version: int | None = None
_cancel_update: CALLBACK_TYPE | None = None
_attr_supported_features = (
UpdateEntityFeature.INSTALL
@@ -93,9 +92,6 @@ class MatterUpdate(MatterEntity, UpdateEntity):
def _update_from_device(self) -> None:
"""Update from device."""
self._installed_software_version = self.get_matter_attribute_value(
clusters.BasicInformation.Attributes.SoftwareVersion
)
self._attr_installed_version = self.get_matter_attribute_value(
clusters.BasicInformation.Attributes.SoftwareVersionString
)
@@ -127,22 +123,6 @@ class MatterUpdate(MatterEntity, UpdateEntity):
else:
self._attr_update_percentage = None
def _format_latest_version(
self, update_information: MatterSoftwareVersion
) -> str | None:
"""Return the version string to expose in Home Assistant."""
latest_version = update_information.software_version_string
if self._installed_software_version is None:
return latest_version
if update_information.software_version == self._installed_software_version:
return self._attr_installed_version or latest_version
if latest_version == self._attr_installed_version:
return f"{latest_version} ({update_information.software_version})"
return latest_version
async def async_update(self) -> None:
"""Call when the entity needs to be updated."""
try:
@@ -150,13 +130,11 @@ class MatterUpdate(MatterEntity, UpdateEntity):
node_id=self._endpoint.node.node_id
)
if not update_information:
self._software_update = None
self._attr_latest_version = self._attr_installed_version
self._attr_release_url = None
return
self._software_update = update_information
self._attr_latest_version = self._format_latest_version(update_information)
self._attr_latest_version = update_information.software_version_string
self._attr_release_url = update_information.release_notes_url
except UpdateCheckError as err:
@@ -234,12 +212,7 @@ class MatterUpdate(MatterEntity, UpdateEntity):
software_version: str | int | None = version
if self._software_update is not None and (
version is None
or version
in {
self._software_update.software_version_string,
self._attr_latest_version,
}
version is None or version == self._software_update.software_version_string
):
# Update to the version previously fetched and shown.
# We can pass the integer version directly to speedup download.

View File

@@ -1,38 +1,72 @@
"""Support for Meteo-France weather data."""
from datetime import timedelta
import logging
from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department
from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain
from requests import RequestException
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_CITY,
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
PLATFORMS,
)
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
SCAN_INTERVAL = timedelta(minutes=15)
CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string})
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Meteo-France account from a config entry."""
"""Set up an Meteo-France account from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = MeteoFranceClient()
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
coordinator_forecast = MeteoFranceForecastUpdateCoordinator(hass, entry, client)
async def _async_update_data_forecast_forecast() -> Forecast:
"""Fetch data from API endpoint."""
return await hass.async_add_executor_job(
client.get_forecast, latitude, longitude
)
async def _async_update_data_rain() -> Rain:
"""Fetch data from API endpoint."""
return await hass.async_add_executor_job(client.get_rain, latitude, longitude)
async def _async_update_data_alert() -> CurrentPhenomenons:
"""Fetch data from API endpoint."""
assert isinstance(department, str)
return await hass.async_add_executor_job(
client.get_warning_current_phenomenons, department, 0, True
)
coordinator_forecast = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"Météo-France forecast for city {entry.title}",
config_entry=entry,
update_method=_async_update_data_forecast_forecast,
update_interval=SCAN_INTERVAL,
)
coordinator_rain = None
coordinator_alert = None
@@ -43,7 +77,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady
# Check rain forecast.
coordinator_rain = MeteoFranceRainUpdateCoordinator(hass, entry, client)
coordinator_rain = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"Météo-France rain for city {entry.title}",
config_entry=entry,
update_method=_async_update_data_rain,
update_interval=SCAN_INTERVAL_RAIN,
)
try:
await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001
except RequestException:
@@ -60,11 +101,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
if department is not None and is_valid_warning_department(department):
if not hass.data[DOMAIN].get(department):
coordinator_alert = MeteoFranceAlertUpdateCoordinator(
coordinator_alert = DataUpdateCoordinator(
hass,
entry,
client,
department,
_LOGGER,
name=f"Météo-France alert for department {department}",
config_entry=entry,
update_method=_async_update_data_alert,
update_interval=SCAN_INTERVAL,
)
await coordinator_alert.async_refresh()

View File

@@ -1,107 +0,0 @@
"""Support for Meteo-France weather data."""
from datetime import timedelta
import logging
from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
SCAN_INTERVAL = timedelta(minutes=15)
class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
"""Coordinator for Meteo-France forecast data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"Météo-France forecast for city {entry.title}",
config_entry=entry,
update_interval=SCAN_INTERVAL,
)
self._client = client
self._latitude = entry.data[CONF_LATITUDE]
self._longitude = entry.data[CONF_LONGITUDE]
async def _async_update_data(self) -> Forecast:
"""Get data from Meteo-France forecast."""
return await self.hass.async_add_executor_job(
self._client.get_forecast, self._latitude, self._longitude
)
class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
"""Coordinator for Meteo-France rain data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"Météo-France rain for city {entry.title}",
config_entry=entry,
update_interval=SCAN_INTERVAL_RAIN,
)
self._client = client
self._latitude = entry.data[CONF_LATITUDE]
self._longitude = entry.data[CONF_LONGITUDE]
async def _async_update_data(self) -> Rain:
"""Get data from Meteo-France rain."""
return await self.hass.async_add_executor_job(
self._client.get_rain, self._latitude, self._longitude
)
class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]):
"""Coordinator for Meteo-France alert data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: MeteoFranceClient,
department: str,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"Météo-France alert for department {department}",
config_entry=entry,
update_interval=SCAN_INTERVAL,
)
self._client = client
self._department = department
async def _async_update_data(self) -> CurrentPhenomenons:
"""Get data from Meteo-France alert."""
return await self.hass.async_add_executor_job(
self._client.get_warning_current_phenomenons, self._department, 0, True
)

View File

@@ -48,11 +48,6 @@ from .const import (
MANUFACTURER,
MODEL,
)
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
@dataclass(frozen=True, kw_only=True)
@@ -193,13 +188,9 @@ async def async_setup_entry(
) -> None:
"""Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[
COORDINATOR_FORECAST
]
coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get(
COORDINATOR_RAIN
)
coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get(
coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST]
coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN)
coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get(
COORDINATOR_ALERT
)
@@ -325,7 +316,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]):
def __init__(
self,
coordinator: MeteoFranceAlertUpdateCoordinator,
coordinator: DataUpdateCoordinator[CurrentPhenomenons],
description: MeteoFranceSensorEntityDescription,
) -> None:
"""Initialize the Meteo-France sensor."""

View File

@@ -3,6 +3,8 @@
import logging
import time
from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SUNNY,
@@ -29,7 +31,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.util import dt as dt_util
from .const import (
@@ -42,7 +47,6 @@ from .const import (
MANUFACTURER,
MODEL,
)
from .coordinator import MeteoFranceForecastUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -62,7 +66,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France weather platform."""
coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][
coordinator: DataUpdateCoordinator[MeteoFranceForecast] = hass.data[DOMAIN][
entry.entry_id
][COORDINATOR_FORECAST]
@@ -83,7 +87,7 @@ async def async_setup_entry(
class MeteoFranceWeather(
CoordinatorEntity[MeteoFranceForecastUpdateCoordinator], WeatherEntity
CoordinatorEntity[DataUpdateCoordinator[MeteoFranceForecast]], WeatherEntity
):
"""Representation of a weather condition."""
@@ -97,7 +101,7 @@ class MeteoFranceWeather(
)
def __init__(
self, coordinator: MeteoFranceForecastUpdateCoordinator, mode: str
self, coordinator: DataUpdateCoordinator[MeteoFranceForecast], mode: str
) -> None:
"""Initialise the platform with a data instance and station name."""
super().__init__(coordinator)

View File

@@ -156,15 +156,17 @@ class MoldIndicator(SensorEntity):
"""Initialize the sensor."""
self._attr_name = name
self._attr_unique_id = unique_id
self._entities = {
CONF_INDOOR_TEMP: indoor_temp_sensor,
CONF_OUTDOOR_TEMP: outdoor_temp_sensor,
CONF_INDOOR_HUMIDITY: indoor_humidity_sensor,
}
self._indoor_temp_sensor = indoor_temp_sensor
self._indoor_humidity_sensor = indoor_humidity_sensor
self._outdoor_temp_sensor = outdoor_temp_sensor
self._calib_factor = calib_factor
self._is_metric = is_metric
self._attr_available = False
self._entities = {
indoor_temp_sensor,
indoor_humidity_sensor,
outdoor_temp_sensor,
}
self._dewpoint: float | None = None
self._indoor_temp: float | None = None
self._outdoor_temp: float | None = None
@@ -184,7 +186,12 @@ class MoldIndicator(SensorEntity):
) -> CALLBACK_TYPE:
"""Render a preview."""
# Abort early if there is no source entity_id's or calibration factor
if not all((*self._entities.values(), self._calib_factor)):
if (
not self._outdoor_temp_sensor
or not self._indoor_temp_sensor
or not self._indoor_humidity_sensor
or not self._calib_factor
):
self._attr_available = False
calculated_state = self._async_calculate_state()
preview_callback(calculated_state.state, calculated_state.attributes)
@@ -234,24 +241,22 @@ class MoldIndicator(SensorEntity):
_LOGGER.debug("Startup for %s", self.entity_id)
async_track_state_change_event(
self.hass,
list(self._entities.values()),
mold_indicator_sensors_state_listener,
self.hass, list(self._entities), mold_indicator_sensors_state_listener
)
# Read initial state
indoor_temp = self.hass.states.get(self._entities[CONF_INDOOR_TEMP])
outdoor_temp = self.hass.states.get(self._entities[CONF_OUTDOOR_TEMP])
indoor_hum = self.hass.states.get(self._entities[CONF_INDOOR_HUMIDITY])
indoor_temp = self.hass.states.get(self._indoor_temp_sensor)
outdoor_temp = self.hass.states.get(self._outdoor_temp_sensor)
indoor_hum = self.hass.states.get(self._indoor_humidity_sensor)
schedule_update = self._update_sensor(
self._entities[CONF_INDOOR_TEMP], None, indoor_temp
self._indoor_temp_sensor, None, indoor_temp
)
schedule_update = (
False
if not self._update_sensor(
self._entities[CONF_OUTDOOR_TEMP], None, outdoor_temp
self._outdoor_temp_sensor, None, outdoor_temp
)
else schedule_update
)
@@ -259,7 +264,7 @@ class MoldIndicator(SensorEntity):
schedule_update = (
False
if not self._update_sensor(
self._entities[CONF_INDOOR_HUMIDITY], None, indoor_hum
self._indoor_humidity_sensor, None, indoor_hum
)
else schedule_update
)
@@ -294,87 +299,92 @@ class MoldIndicator(SensorEntity):
if old_state is None and new_state.state == STATE_UNKNOWN:
return False
if entity == self._entities[CONF_INDOOR_TEMP]:
self._indoor_temp = self._get_temperature_from_state(new_state)
elif entity == self._entities[CONF_OUTDOOR_TEMP]:
self._outdoor_temp = self._get_temperature_from_state(new_state)
elif entity == self._entities[CONF_INDOOR_HUMIDITY]:
self._indoor_hum = self._get_humidity_from_state(new_state)
if entity == self._indoor_temp_sensor:
self._indoor_temp = self._update_temp_sensor(new_state)
elif entity == self._outdoor_temp_sensor:
self._outdoor_temp = self._update_temp_sensor(new_state)
elif entity == self._indoor_humidity_sensor:
self._indoor_hum = self._update_hum_sensor(new_state)
return True
def _get_value_from_state(
self,
state: State | None,
validator: Callable[[float, str | None], float | None],
) -> float | None:
"""Get and validate a sensor value from state."""
if state is None:
return None
@staticmethod
def _update_temp_sensor(state: State) -> float | None:
"""Parse temperature sensor value."""
_LOGGER.debug("Updating temp sensor with value %s", state.state)
# Return an error if the sensor change its state to Unknown.
if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
_LOGGER.debug(
"Unable to get sensor %s, state: %s",
"Unable to parse temperature sensor %s with state: %s",
state.entity_id,
state.state,
)
return None
if (value := util.convert(state.state, float)) is None:
if (temp := util.convert(state.state, float)) is None:
_LOGGER.error(
"Unable to parse temperature sensor %s with state: %s",
state.entity_id,
state.state,
)
return None
# convert to celsius if necessary
if (
unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
) in UnitOfTemperature:
return TemperatureConverter.convert(temp, unit, UnitOfTemperature.CELSIUS)
_LOGGER.error(
"Temp sensor %s has unsupported unit: %s (allowed: %s, %s)",
state.entity_id,
unit,
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
return None
@staticmethod
def _update_hum_sensor(state: State) -> float | None:
"""Parse humidity sensor value."""
_LOGGER.debug("Updating humidity sensor with value %s", state.state)
# Return an error if the sensor change its state to Unknown.
if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
_LOGGER.debug(
"Unable to parse sensor value %s, state: %s to float",
"Unable to parse humidity sensor %s, state: %s",
state.entity_id,
state.state,
)
return None
return validator(value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
if (hum := util.convert(state.state, float)) is None:
_LOGGER.error(
"Unable to parse humidity sensor %s, state: %s",
state.entity_id,
state.state,
)
return None
def _get_temperature_from_state(self, state: State | None) -> float | None:
"""Get temperature value in Celsius from state."""
if (unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) != PERCENTAGE:
_LOGGER.error(
"Humidity sensor %s has unsupported unit: %s (allowed: %s)",
state.entity_id,
unit,
PERCENTAGE,
)
return None
def validate_temperature(value: float, unit: str | None) -> float | None:
if TYPE_CHECKING:
assert state is not None
if hum > 100 or hum < 0:
_LOGGER.error(
"Humidity sensor %s is out of range: %s (allowed: 0-100)",
state.entity_id,
hum,
)
return None
if unit not in UnitOfTemperature:
_LOGGER.warning(
"Temp sensor %s has unsupported unit: %s (allowed: %s, %s)",
state.entity_id,
unit,
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
return None
return TemperatureConverter.convert(value, unit, UnitOfTemperature.CELSIUS)
return self._get_value_from_state(state, validate_temperature)
def _get_humidity_from_state(self, state: State | None) -> float | None:
"""Get humidity value from state."""
def validate_humidity(value: float, unit: str | None) -> float | None:
if TYPE_CHECKING:
assert state is not None
if unit != PERCENTAGE:
_LOGGER.warning(
"Humidity sensor %s has unsupported unit: %s (allowed: %s)",
state.entity_id,
unit,
PERCENTAGE,
)
return None
if not 0 <= value <= 100:
_LOGGER.warning(
"Humidity sensor %s is out of range: %s (allowed: 0-100)",
state.entity_id,
value,
)
return None
return value
return self._get_value_from_state(state, validate_humidity)
return hum
async def async_update(self) -> None:
"""Calculate latest state."""
@@ -415,7 +425,7 @@ class MoldIndicator(SensorEntity):
_LOGGER.debug("Dewpoint: %f %s", self._dewpoint, UnitOfTemperature.CELSIUS)
def _calc_moldindicator(self) -> None:
"""Calculate the mold indicator value."""
"""Calculate the humidity at the (cold) calibration point."""
if TYPE_CHECKING:
assert self._outdoor_temp and self._indoor_temp and self._dewpoint
@@ -426,6 +436,7 @@ class MoldIndicator(SensorEntity):
self._calib_factor,
)
self._attr_native_value = None
self._attr_available = False
self._crit_temp = None
return
@@ -453,13 +464,13 @@ class MoldIndicator(SensorEntity):
* 100.0
)
# truncate humidity
# check bounds and format
if crit_humidity > 100:
self._attr_native_value = 100
self._attr_native_value = "100"
elif crit_humidity < 0:
self._attr_native_value = 0
self._attr_native_value = "0"
else:
self._attr_native_value = int(crit_humidity)
self._attr_native_value = f"{int(crit_humidity):d}"
_LOGGER.debug("Mold indicator humidity: %s", self.native_value)

View File

@@ -7,15 +7,36 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _MotionBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for motion binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
class MotionDetectedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion detected (binary sensor ON)."""
_to_states = {STATE_ON}
class MotionClearedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
_MOTION_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_ON),
"cleared": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_OFF),
"detected": MotionDetectedTrigger,
"cleared": MotionClearedTrigger,
}

View File

@@ -7,15 +7,40 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for occupancy binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
}
class OccupancyDetectedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy detected (binary sensor ON)."""
_to_states = {STATE_ON}
class OccupancyClearedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
_OCCUPANCY_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_ON),
"cleared": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_OFF),
"detected": OccupancyDetectedTrigger,
"cleared": OccupancyClearedTrigger,
}

View File

@@ -10,13 +10,9 @@ import httpx
import ollama
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_API_KEY, CONF_URL, Platform
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -66,28 +62,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool:
"""Set up Ollama from a config entry."""
settings = {**entry.data, **entry.options}
api_key = settings.get(CONF_API_KEY)
stripped_api_key = api_key.strip() if isinstance(api_key, str) else None
client = ollama.AsyncClient(
host=settings[CONF_URL],
headers=(
{"Authorization": f"Bearer {stripped_api_key}"}
if stripped_api_key
else None
),
verify=get_default_context(),
)
client = ollama.AsyncClient(host=settings[CONF_URL], verify=get_default_context())
try:
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
except ollama.ResponseError as err:
if err.status_code in (401, 403):
raise ConfigEntryAuthFailed from err
if err.status_code >= 500 or err.status_code == 429:
raise ConfigEntryNotReady(err) from err
# If the response is a 4xx error other than 401 or 403, it likely means the URL is valid but not an Ollama instance,
# so we raise ConfigEntryError to show an error in the UI, instead of ConfigEntryNotReady which would just keep retrying.
raise ConfigEntryError(err) from err
except (TimeoutError, httpx.ConnectError) as err:
raise ConfigEntryNotReady(err) from err

View File

@@ -20,7 +20,7 @@ from homeassistant.config_entries import (
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers.selector import (
@@ -68,17 +68,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Required(CONF_URL): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
},
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
@@ -89,40 +78,9 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 3
MINOR_VERSION = 3
async def _async_validate_connection(
self, url: str, api_key: str | None
) -> dict[str, str]:
"""Validate connection and credentials against the Ollama server."""
errors: dict[str, str] = {}
try:
client = ollama.AsyncClient(
host=url,
headers={"Authorization": f"Bearer {api_key}"} if api_key else None,
verify=get_default_context(),
)
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
except ollama.ResponseError as err:
if err.status_code in (401, 403):
errors["base"] = "invalid_auth"
else:
_LOGGER.warning(
"Error response from Ollama server at %s: status %s, detail: %s",
url,
err.status_code,
str(err),
)
errors["base"] = "unknown"
except TimeoutError, httpx.ConnectError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors
def __init__(self) -> None:
"""Initialize config flow."""
self.url: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -134,10 +92,9 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
)
errors = {}
url = user_input[CONF_URL].strip()
api_key = user_input.get(CONF_API_KEY)
if api_key:
api_key = api_key.strip()
url = user_input[CONF_URL]
self._async_abort_entries_match({CONF_URL: url})
try:
url = cv.url(url)
@@ -151,8 +108,15 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
self._async_abort_entries_match({CONF_URL: url})
errors = await self._async_validate_connection(url, api_key)
try:
client = ollama.AsyncClient(host=url, verify=get_default_context())
async with asyncio.timeout(DEFAULT_TIMEOUT):
await client.list()
except TimeoutError, httpx.ConnectError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if errors:
return self.async_show_form(
@@ -163,65 +127,9 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
entry_data: dict[str, str] = {CONF_URL: url}
if api_key:
entry_data[CONF_API_KEY] = api_key
return self.async_create_entry(title=url, data=entry_data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication when existing credentials are invalid."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
reauth_entry = self._get_reauth_entry()
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
)
api_key = user_input.get(CONF_API_KEY)
if api_key:
api_key = api_key.strip()
errors = await self._async_validate_connection(
reauth_entry.data[CONF_URL], api_key
)
if errors:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
STEP_REAUTH_DATA_SCHEMA, user_input
),
errors=errors,
)
updated_data = {
**reauth_entry.data,
CONF_URL: reauth_entry.data[CONF_URL],
}
if api_key:
updated_data[CONF_API_KEY] = api_key
else:
updated_data.pop(CONF_API_KEY, None)
updated_options = {
key: value
for key, value in reauth_entry.options.items()
if key != CONF_API_KEY
}
return self.async_update_reload_and_abort(
reauth_entry,
data=updated_data,
options=updated_options,
return self.async_create_entry(
title=url,
data={CONF_URL: url},
)
@classmethod

View File

@@ -1,26 +1,16 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_url": "[%key:common::config_flow::error::invalid_host%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"description": "The Ollama integration needs to re-authenticate with your Ollama API key.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"url": "[%key:common::config_flow::data::url%]"
}
}

View File

@@ -17,7 +17,6 @@ from onvif.client import (
from onvif.exceptions import ONVIFError
from onvif.util import stringify_onvif_error
import onvif_parsers
import onvif_parsers.util
from zeep.exceptions import Fault, TransportError, ValidationError, XMLParseError
from homeassistant.components import webhook
@@ -197,7 +196,7 @@ class EventManager:
topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001
try:
events = await onvif_parsers.parse(topic, unique_id, msg)
event = await onvif_parsers.parse(topic, unique_id, msg)
error = None
except onvif_parsers.errors.UnknownTopicError:
if topic not in UNHANDLED_TOPICS:
@@ -205,43 +204,42 @@ class EventManager:
"%s: No registered handler for event from %s: %s",
self.name,
unique_id,
onvif_parsers.util.event_to_debug_format(msg),
msg,
)
UNHANDLED_TOPICS.add(topic)
continue
except (AttributeError, KeyError) as e:
events = []
event = None
error = e
if not events:
if not event:
LOGGER.warning(
"%s: Unable to parse event from %s: %s: %s",
self.name,
unique_id,
error,
onvif_parsers.util.event_to_debug_format(msg),
msg,
)
continue
for event in events:
value = event.value
if event.device_class == "timestamp" and isinstance(value, str):
value = _local_datetime_or_none(value)
value = event.value
if event.device_class == "timestamp" and isinstance(value, str):
value = _local_datetime_or_none(value)
ha_event = Event(
uid=event.uid,
name=event.name,
platform=event.platform,
device_class=event.device_class,
unit_of_measurement=event.unit_of_measurement,
value=value,
entity_category=ENTITY_CATEGORY_MAPPING.get(
event.entity_category or ""
),
entity_enabled=event.entity_enabled,
)
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
self._events[ha_event.uid] = ha_event
ha_event = Event(
uid=event.uid,
name=event.name,
platform=event.platform,
device_class=event.device_class,
unit_of_measurement=event.unit_of_measurement,
value=value,
entity_category=ENTITY_CATEGORY_MAPPING.get(
event.entity_category or ""
),
entity_enabled=event.entity_enabled,
)
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
self._events[ha_event.uid] = ha_event
def get_uid(self, uid: str) -> Event | None:
"""Retrieve event for given id."""

View File

@@ -15,7 +15,7 @@
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": [
"onvif-zeep-async==4.0.4",
"onvif_parsers==2.3.0",
"onvif_parsers==1.2.2",
"WSDiscovery==2.1.2"
]
}

View File

@@ -54,7 +54,6 @@ from .const import (
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_RECOMMENDED,
CONF_SERVICE_TIER,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_TTS_SPEED,
@@ -81,7 +80,6 @@ from .const import (
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STT_MODEL,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
@@ -94,10 +92,8 @@ from .const import (
RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS,
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
UNSUPPORTED_CODE_INTERPRETER_MODELS,
UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS,
UNSUPPORTED_IMAGE_MODELS,
UNSUPPORTED_MODELS,
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS,
UNSUPPORTED_WEB_SEARCH_MODELS,
)
@@ -447,25 +443,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
if not model.startswith("gpt-5"):
options.pop(CONF_REASONING_SUMMARY)
service_tiers = self._get_service_tiers(model)
if "flex" in service_tiers or "priority" in service_tiers:
step_schema[
vol.Optional(
CONF_SERVICE_TIER,
default=RECOMMENDED_SERVICE_TIER,
)
] = SelectSelector(
SelectSelectorConfig(
options=service_tiers,
translation_key=CONF_SERVICE_TIER,
mode=SelectSelectorMode.DROPDOWN,
)
)
else:
options.pop(CONF_SERVICE_TIER, None)
if options.get(CONF_SERVICE_TIER) not in service_tiers:
options.pop(CONF_SERVICE_TIER, None)
if self._subentry_type == "conversation" and not model.startswith(
tuple(UNSUPPORTED_WEB_SEARCH_MODELS)
):
@@ -586,20 +563,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
return options
return [] # pragma: no cover
def _get_service_tiers(self, model: str) -> list[str]:
"""Get service tier options based on model."""
service_tiers = ["auto"]
if not model.startswith(tuple(UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS)):
service_tiers.append("flex")
service_tiers.append("default")
if not model.startswith(tuple(UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS)):
service_tiers.append("priority")
return service_tiers
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""
location_data: dict[str, str] = {}

View File

@@ -24,7 +24,6 @@ CONF_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
CONF_REASONING_SUMMARY = "reasoning_summary"
CONF_RECOMMENDED = "recommended"
CONF_SERVICE_TIER = "service_tier"
CONF_TEMPERATURE = "temperature"
CONF_TOP_P = "top_p"
CONF_TTS_SPEED = "tts_speed"
@@ -43,7 +42,6 @@ RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
RECOMMENDED_MAX_TOKENS = 3000
RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_REASONING_SUMMARY = "auto"
RECOMMENDED_SERVICE_TIER = "auto"
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"
RECOMMENDED_TEMPERATURE = 1.0
RECOMMENDED_TOP_P = 1.0
@@ -121,38 +119,3 @@ RECOMMENDED_TTS_OPTIONS = {
CONF_PROMPT: "",
CONF_CHAT_MODEL: "gpt-4o-mini-tts",
}
UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS: list[str] = [
"gpt-5.3",
"gpt-5.2-chat",
"gpt-5.1-chat",
"gpt-5-chat",
"gpt-5.2-codex",
"gpt-5.1-codex",
"gpt-5-codex",
"gpt-5.2-pro",
"gpt-5-pro",
"gpt-4",
"o1",
"o3-pro",
"o3-deep-research",
"o4-mini-deep-research",
"o3-mini",
"codex-mini",
]
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS: list[str] = [
"gpt-5-nano",
"gpt-5.3-chat",
"gpt-5.2-chat",
"gpt-5.1-chat",
"gpt-5.1-codex-mini",
"gpt-5-chat",
"gpt-5.2-pro",
"gpt-5-pro",
"o1",
"o3-pro",
"o3-deep-research",
"o4-mini-deep-research",
"o3-mini",
"codex-mini",
]

View File

@@ -74,7 +74,6 @@ from .const import (
CONF_MAX_TOKENS,
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_SERVICE_TIER,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_VERBOSITY,
@@ -93,7 +92,6 @@ from .const import (
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STT_MODEL,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
@@ -501,7 +499,6 @@ class OpenAIBaseLLMEntity(Entity):
input=messages,
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
user=chat_log.conversation_id,
service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER),
store=False,
stream=True,
)
@@ -658,15 +655,6 @@ class OpenAIBaseLLMEntity(Entity):
)
)
except openai.RateLimitError as err:
if (
model_args["service_tier"] == "flex"
and "resource unavailable" in (err.message or "").lower()
):
LOGGER.info(
"Flex tier is not available at the moment, continuing with default tier"
)
model_args["service_tier"] = "default"
continue
LOGGER.error("Rate limited by OpenAI: %s", err)
raise HomeAssistantError("Rate limited or insufficient funds") from err
except openai.OpenAIError as err:

View File

@@ -70,7 +70,6 @@
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]",
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]",
"service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::service_tier%]",
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]",
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]"
},
@@ -81,7 +80,6 @@
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]",
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]",
"service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::service_tier%]",
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]",
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]"
},
@@ -133,7 +131,6 @@
"reasoning_effort": "Reasoning effort",
"reasoning_summary": "Reasoning summary",
"search_context_size": "Search context size",
"service_tier": "Service tier",
"user_location": "Include home location",
"web_search": "Enable web search"
},
@@ -144,7 +141,6 @@
"reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt",
"reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model",
"search_context_size": "High level guidance for the amount of context window space to use for the search",
"service_tier": "Controls the cost and response time",
"user_location": "Refine search results based on geography",
"web_search": "Allow the model to search the web for the latest information before generating a response"
},
@@ -246,14 +242,6 @@
"medium": "[%key:common::state::medium%]"
}
},
"service_tier": {
"options": {
"auto": "[%key:common::state::auto%]",
"default": "Standard",
"flex": "Flex",
"priority": "Priority"
}
},
"verbosity": {
"options": {
"high": "[%key:common::state::high%]",

View File

@@ -4,7 +4,6 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/orvibo",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["orvibo"],
"quality_scale": "legacy",

View File

@@ -20,7 +20,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType
@@ -37,7 +37,6 @@ DEFAULT_SEND_DELAY = 0.0
DOMAIN = "pilight"
EVENT = "pilight_received"
type EVENT_TYPE = Event[dict[str, Any]]
# The Pilight code schema depends on the protocol. Thus only require to have
# the protocol information. Ensure that protocol is in a list otherwise

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import datetime
from typing import Any
import voluptuous as vol
@@ -25,7 +24,7 @@ from homeassistant.helpers.event import track_point_in_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import EVENT, EVENT_TYPE
from . import EVENT
CONF_VARIABLE = "variable"
CONF_RESET_DELAY_SEC = "reset_delay_sec"
@@ -47,8 +46,6 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
}
)
type _PAYLOAD_SET_TYPE = str | int | float
def setup_platform(
hass: HomeAssistant,
@@ -62,12 +59,12 @@ def setup_platform(
[
PilightTriggerSensor(
hass=hass,
name=config[CONF_NAME],
variable=config[CONF_VARIABLE],
payload=config[CONF_PAYLOAD],
on_value=config[CONF_PAYLOAD_ON],
off_value=config[CONF_PAYLOAD_OFF],
rst_dly_sec=config[CONF_RESET_DELAY_SEC],
name=config.get(CONF_NAME),
variable=config.get(CONF_VARIABLE),
payload=config.get(CONF_PAYLOAD),
on_value=config.get(CONF_PAYLOAD_ON),
off_value=config.get(CONF_PAYLOAD_OFF),
rst_dly_sec=config.get(CONF_RESET_DELAY_SEC),
)
]
)
@@ -76,11 +73,11 @@ def setup_platform(
[
PilightBinarySensor(
hass=hass,
name=config[CONF_NAME],
variable=config[CONF_VARIABLE],
payload=config[CONF_PAYLOAD],
on_value=config[CONF_PAYLOAD_ON],
off_value=config[CONF_PAYLOAD_OFF],
name=config.get(CONF_NAME),
variable=config.get(CONF_VARIABLE),
payload=config.get(CONF_PAYLOAD),
on_value=config.get(CONF_PAYLOAD_ON),
off_value=config.get(CONF_PAYLOAD_OFF),
)
]
)
@@ -89,15 +86,7 @@ def setup_platform(
class PilightBinarySensor(BinarySensorEntity):
"""Representation of a binary sensor that can be updated using Pilight."""
def __init__(
self,
hass: HomeAssistant,
name: str,
variable: str,
payload: dict[str, Any],
on_value: _PAYLOAD_SET_TYPE,
off_value: _PAYLOAD_SET_TYPE,
) -> None:
def __init__(self, hass, name, variable, payload, on_value, off_value):
"""Initialize the sensor."""
self._attr_is_on = False
self._hass = hass
@@ -109,7 +98,7 @@ class PilightBinarySensor(BinarySensorEntity):
hass.bus.listen(EVENT, self._handle_code)
def _handle_code(self, call: EVENT_TYPE) -> None:
def _handle_code(self, call):
"""Handle received code by the pilight-daemon.
If the code matches the defined payload
@@ -137,15 +126,8 @@ class PilightTriggerSensor(BinarySensorEntity):
"""Representation of a binary sensor that can be updated using Pilight."""
def __init__(
self,
hass: HomeAssistant,
name: str,
variable: str,
payload: dict[str, Any],
on_value: _PAYLOAD_SET_TYPE,
off_value: _PAYLOAD_SET_TYPE,
rst_dly_sec: int,
) -> None:
self, hass, name, variable, payload, on_value, off_value, rst_dly_sec=30
):
"""Initialize the sensor."""
self._attr_is_on = False
self._hass = hass
@@ -155,17 +137,17 @@ class PilightTriggerSensor(BinarySensorEntity):
self._on_value = on_value
self._off_value = off_value
self._reset_delay_sec = rst_dly_sec
self._delay_after: datetime.datetime | None = None
self._delay_after = None
self._hass = hass
hass.bus.listen(EVENT, self._handle_code)
def _reset_state(self, _: datetime.datetime) -> None:
def _reset_state(self, call):
self._attr_is_on = False
self._delay_after = None
self.schedule_update_ha_state()
def _handle_code(self, call: EVENT_TYPE) -> None:
def _handle_code(self, call):
"""Handle received code by the pilight-daemon.
If the code matches the defined payload

View File

@@ -1,7 +1,5 @@
"""Base class for pilight."""
from typing import Any
import voluptuous as vol
from homeassistant.const import (
@@ -12,10 +10,8 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from . import DOMAIN, EVENT, SERVICE_NAME
from .const import (
@@ -64,19 +60,19 @@ class PilightBaseDevice(RestoreEntity):
_attr_assumed_state = True
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
def __init__(self, hass, name, config):
"""Initialize a device."""
self._hass = hass
self._attr_name = config.get(CONF_NAME, name)
self._attr_is_on: bool | None = False
self._attr_is_on = False
self._code_on = config.get(CONF_ON_CODE)
self._code_off = config.get(CONF_OFF_CODE)
code_on_receive = config.get(CONF_ON_CODE_RECEIVE, [])
code_off_receive = config.get(CONF_OFF_CODE_RECEIVE, [])
self._code_on_receive: list[_ReceiveHandle] = []
self._code_off_receive: list[_ReceiveHandle] = []
self._code_on_receive = []
self._code_off_receive = []
for code_list, conf in (
(self._code_on_receive, code_on_receive),
@@ -89,7 +85,7 @@ class PilightBaseDevice(RestoreEntity):
if any(self._code_on_receive) or any(self._code_off_receive):
hass.bus.listen(EVENT, self._handle_code)
self._brightness: int | None = 255
self._brightness = 255
async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
@@ -151,18 +147,18 @@ class PilightBaseDevice(RestoreEntity):
class _ReceiveHandle:
def __init__(self, config: dict[str, Any], echo: bool) -> None:
def __init__(self, config, echo):
"""Initialize the handle."""
self.config_items = config.items()
self.echo = echo
def match(self, code: dict[str, Any]) -> bool:
def match(self, code):
"""Test if the received code matches the configured values.
The received values have to be a subset of the configured options.
"""
return self.config_items <= code.items()
def run(self, switch: PilightBaseDevice, turn_on: bool) -> None:
def run(self, switch, turn_on):
"""Change the state of the switch."""
switch.set_state(turn_on=turn_on, send_code=self.echo)

View File

@@ -55,11 +55,11 @@ class PilightLight(PilightBaseDevice, LightEntity):
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(self, hass: HomeAssistant, name: str, config: ConfigType) -> None:
def __init__(self, hass, name, config):
"""Initialize a switch."""
super().__init__(hass, name, config)
self._dimlevel_min: int = config[CONF_DIMLEVEL_MIN]
self._dimlevel_max: int = config[CONF_DIMLEVEL_MAX]
self._dimlevel_min = config.get(CONF_DIMLEVEL_MIN)
self._dimlevel_max = config.get(CONF_DIMLEVEL_MAX)
@property
def brightness(self) -> int | None:

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -17,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import EVENT, EVENT_TYPE
from . import EVENT
_LOGGER = logging.getLogger(__name__)
@@ -45,9 +44,9 @@ def setup_platform(
[
PilightSensor(
hass=hass,
name=config[CONF_NAME],
variable=config[CONF_VARIABLE],
payload=config[CONF_PAYLOAD],
name=config.get(CONF_NAME),
variable=config.get(CONF_VARIABLE),
payload=config.get(CONF_PAYLOAD),
unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT),
)
]
@@ -59,24 +58,33 @@ class PilightSensor(SensorEntity):
_attr_should_poll = False
def __init__(
self,
hass: HomeAssistant,
name: str,
variable: str,
payload: dict[str, Any],
unit_of_measurement: str | None,
) -> None:
def __init__(self, hass, name, variable, payload, unit_of_measurement):
"""Initialize the sensor."""
self._state = None
self._hass = hass
self._attr_name = name
self._name = name
self._variable = variable
self._payload = payload
self._attr_native_unit_of_measurement = unit_of_measurement
self._unit_of_measurement = unit_of_measurement
hass.bus.listen(EVENT, self._handle_code)
def _handle_code(self, call: EVENT_TYPE) -> None:
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def native_unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
@property
def native_value(self):
"""Return the state of the entity."""
return self._state
def _handle_code(self, call):
"""Handle received code by the pilight-daemon.
If the code matches the defined payload
@@ -88,7 +96,7 @@ class PilightSensor(SensorEntity):
if self._payload.items() <= call.data.items():
try:
value = call.data[self._variable]
self._attr_native_value = value
self._state = value
self.schedule_update_ha_state()
except KeyError:
_LOGGER.error(

View File

@@ -36,6 +36,11 @@ type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator]
class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]):
"""Class to manage fetching Plugwise data from single endpoint."""
_connected: bool = False
_current_devices: set[str]
_stored_devices: set[str]
new_devices: set[str]
config_entry: PlugwiseConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: PlugwiseConfigEntry) -> None:
@@ -63,10 +68,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT),
websession=async_get_clientsession(hass, verify_ssl=False),
)
self._connected: bool = False
self._current_devices: set[str] = set()
self._stored_devices: set[str] = set()
self.new_devices: set[str] = set()
self._current_devices = set()
self._stored_devices = set()
self.new_devices = set()
async def _connect(self) -> None:
"""Connect to the Plugwise Smile.
@@ -128,10 +132,10 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
translation_key="unsupported_firmware",
) from err
self._add_remove_devices(data)
self._async_add_remove_devices(data)
return data
def _add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
"""Add new Plugwise devices, remove non-existing devices."""
set_of_data = set(data)
# Check for new or removed devices,
@@ -142,28 +146,35 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
self._stored_devices if not self._current_devices else self._current_devices
)
self._current_devices = set_of_data
if removed_devices := (current_devices - set_of_data): # device(s) to remove
self._remove_devices(removed_devices)
if current_devices - set_of_data: # device(s) to remove
self._async_remove_devices(data)
def _remove_devices(self, removed_devices: set[str]) -> None:
def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None:
"""Clean registries when removed devices found."""
device_reg = dr.async_get(self.hass)
for device_id in removed_devices:
device_entry = device_reg.async_get_device({(DOMAIN, device_id)})
if device_entry is None:
LOGGER.warning(
"Failed to remove %s device/zone %s, not present in device_registry",
DOMAIN,
device_id,
)
continue # pragma: no cover
device_list = dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
)
device_reg.async_update_device(
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
)
LOGGER.debug(
"%s %s %s removed from device_registry",
DOMAIN,
device_entry.model,
device_id,
)
# First find the Plugwise via_device
gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)})
assert gateway_device is not None
via_device_id = gateway_device.id
# Then remove the connected orphaned device(s)
for device_entry in device_list:
for identifier in device_entry.identifiers:
if (
identifier[0] == DOMAIN
and device_entry.via_device_id == via_device_id
and identifier[1] not in data
):
device_reg.async_update_device(
device_entry.id,
remove_config_entry_id=self.config_entry.entry_id,
)
LOGGER.debug(
"Removed %s device/zone %s %s from device_registry",
DOMAIN,
device_entry.model,
identifier[1],
)

View File

@@ -14,7 +14,7 @@ from .coordinator import PranaConfigEntry, PranaCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.FAN, Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.FAN, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:

View File

@@ -8,20 +8,6 @@
"default": "mdi:arrow-expand-left"
}
},
"sensor": {
"inside_temperature": {
"default": "mdi:home-thermometer"
},
"inside_temperature_2": {
"default": "mdi:home-thermometer"
},
"outside_temperature": {
"default": "mdi:thermometer"
},
"outside_temperature_2": {
"default": "mdi:thermometer"
}
},
"switch": {
"auto": {
"default": "mdi:fan-auto"

View File

@@ -1,129 +0,0 @@
"""Sensor platform for Prana integration."""
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UnitOfPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PranaConfigEntry
from .entity import PranaBaseEntity, PranaCoordinator
PARALLEL_UPDATES = 1
class PranaSensorType(StrEnum):
"""Enumerates Prana sensor types exposed by the device API."""
HUMIDITY = "humidity"
VOC = "voc"
AIR_PRESSURE = "air_pressure"
CO2 = "co2"
INSIDE_TEMPERATURE = "inside_temperature"
INSIDE_TEMPERATURE_2 = "inside_temperature_2"
OUTSIDE_TEMPERATURE = "outside_temperature"
OUTSIDE_TEMPERATURE_2 = "outside_temperature_2"
@dataclass(frozen=True, kw_only=True)
class PranaSensorEntityDescription(SensorEntityDescription):
"""Description of a Prana sensor entity."""
key: PranaSensorType
state_class: SensorStateClass = SensorStateClass.MEASUREMENT
value_fn: Callable[[PranaCoordinator], StateType | None]
ENTITIES: tuple[PranaSensorEntityDescription, ...] = (
PranaSensorEntityDescription(
key=PranaSensorType.HUMIDITY,
value_fn=lambda coord: coord.data.humidity,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
),
PranaSensorEntityDescription(
key=PranaSensorType.VOC,
value_fn=lambda coord: coord.data.voc,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
),
PranaSensorEntityDescription(
key=PranaSensorType.AIR_PRESSURE,
value_fn=lambda coord: coord.data.air_pressure,
native_unit_of_measurement=UnitOfPressure.MMHG,
device_class=SensorDeviceClass.PRESSURE,
),
PranaSensorEntityDescription(
key=PranaSensorType.CO2,
value_fn=lambda coord: coord.data.co2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
device_class=SensorDeviceClass.CO2,
),
PranaSensorEntityDescription(
key=PranaSensorType.INSIDE_TEMPERATURE,
translation_key="inside_temperature",
value_fn=lambda coord: coord.data.inside_temperature,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
PranaSensorEntityDescription(
key=PranaSensorType.INSIDE_TEMPERATURE_2,
translation_key="inside_temperature_2",
value_fn=lambda coord: coord.data.inside_temperature_2,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
PranaSensorEntityDescription(
key=PranaSensorType.OUTSIDE_TEMPERATURE,
translation_key="outside_temperature",
value_fn=lambda coord: coord.data.outside_temperature,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
PranaSensorEntityDescription(
key=PranaSensorType.OUTSIDE_TEMPERATURE_2,
translation_key="outside_temperature_2",
value_fn=lambda coord: coord.data.outside_temperature_2,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PranaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Prana sensor entities from a config entry."""
async_add_entities(
PranaSensor(entry.runtime_data, description)
for description in ENTITIES
if description.value_fn(entry.runtime_data) is not None
)
class PranaSensor(PranaBaseEntity, SensorEntity):
"""Representation of a Prana sensor entity."""
entity_description: PranaSensorEntityDescription
@property
def native_value(self) -> StateType | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator)

View File

@@ -49,20 +49,6 @@
}
}
},
"sensor": {
"inside_temperature": {
"name": "Inside temperature"
},
"inside_temperature_2": {
"name": "Inside temperature 2"
},
"outside_temperature": {
"name": "Outside temperature"
},
"outside_temperature_2": {
"name": "Outside temperature 2"
}
},
"switch": {
"auto": {
"name": "Auto"

View File

@@ -1,16 +0,0 @@
"""Common methods for Proxmox VE integration."""
from typing import Any
from homeassistant.const import CONF_USERNAME
from .const import CONF_REALM
def sanitize_userid(data: dict[str, Any]) -> str:
"""Sanitize the user ID."""
return (
data[CONF_USERNAME]
if "@" in data[CONF_USERNAME]
else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}"
)

View File

@@ -23,7 +23,6 @@ from homeassistant.const import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .common import sanitize_userid
from .const import (
CONF_CONTAINERS,
CONF_NODE,
@@ -49,13 +48,22 @@ CONFIG_SCHEMA = vol.Schema(
)
def _sanitize_userid(data: dict[str, Any]) -> str:
"""Sanitize the user ID."""
return (
data[CONF_USERNAME]
if "@" in data[CONF_USERNAME]
else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}"
)
def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Validate the user input and fetch data (sync, for executor)."""
try:
client = ProxmoxAPI(
data[CONF_HOST],
port=data[CONF_PORT],
user=sanitize_userid(data),
user=_sanitize_userid(data),
password=data[CONF_PASSWORD],
verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)

View File

@@ -14,7 +14,13 @@ import requests
from requests.exceptions import ConnectTimeout, SSLError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_VERIFY_SSL
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
@@ -23,8 +29,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .common import sanitize_userid
from .const import CONF_NODE, DEFAULT_VERIFY_SSL, DOMAIN
from .const import CONF_NODE, CONF_REALM, DEFAULT_VERIFY_SSL, DOMAIN
type ProxmoxConfigEntry = ConfigEntry[ProxmoxCoordinator]
@@ -172,10 +177,16 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
def _init_proxmox(self) -> None:
"""Initialize ProxmoxAPI instance."""
user_id = (
self.config_entry.data[CONF_USERNAME]
if "@" in self.config_entry.data[CONF_USERNAME]
else f"{self.config_entry.data[CONF_USERNAME]}@{self.config_entry.data[CONF_REALM]}"
)
self.proxmox = ProxmoxAPI(
host=self.config_entry.data[CONF_HOST],
port=self.config_entry.data[CONF_PORT],
user=sanitize_userid(dict(self.config_entry.data)),
user=user_id,
password=self.config_entry.data[CONF_PASSWORD],
verify_ssl=self.config_entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)

View File

@@ -19,7 +19,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartTubConfigEntry) ->
controller = SmartTubController(hass)
await controller.async_setup_entry(entry)
if not await controller.async_setup_entry(entry):
return False
entry.runtime_data = controller

View File

@@ -1,74 +0,0 @@
rules:
# Bronze
action-setup: todo
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions: todo
docs-high-level-description: todo
docs-installation-instructions: todo
docs-removal-instructions: todo
entity-event-setup:
status: exempt
comment: Entities use coordinator polling, no explicit event subscriptions.
entity-unique-id: done
has-entity-name: todo
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not have configuration parameters.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: done
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery:
status: exempt
comment: This is a cloud-only service with no local discovery mechanism.
discovery-update-info:
status: exempt
comment: This is a cloud-only service with no local discovery mechanism.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Spa devices are fixed to the account and cannot be dynamically added or removed.
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not raise any repairable issues.
stale-devices:
status: exempt
comment: Spa devices are fixed to the account and cannot be removed.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

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