Compare commits

..

2 Commits

Author SHA1 Message Date
jbouwh
98e0513866 Add MQTT select subentry support 2025-10-03 21:08:15 +00:00
jbouwh
972e643d88 Add MQTT number subentry support 2025-10-02 06:01:18 +00:00
341 changed files with 7580 additions and 16806 deletions

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
category: "/language:python"

View File

@@ -326,7 +326,6 @@ homeassistant.components.london_underground.*
homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*
@@ -555,7 +554,6 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
homeassistant.components.volvo.*

4
CODEOWNERS generated
View File

@@ -910,8 +910,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/luci/ @mzdrale
/homeassistant/components/luftdaten/ @fabaff @frenck
/tests/components/luftdaten/ @fabaff @frenck
/homeassistant/components/lunatone/ @MoonDevLT
/tests/components/lunatone/ @MoonDevLT
/homeassistant/components/lupusec/ @majuss @suaveolent
/tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce
@@ -1196,6 +1194,8 @@ build.json @home-assistant/supervisor
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
/tests/components/plugwise/ @CoMPaTech @bouwew
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
/homeassistant/components/point/ @fredrike
/tests/components/point/ @fredrike
/homeassistant/components/pooldose/ @lmaertin

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.3
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.3
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.3
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.3
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.3
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View File

@@ -0,0 +1,5 @@
{
"domain": "ibm",
"name": "IBM",
"integrations": ["watson_iot", "watson_tts"]
}

View File

@@ -23,10 +23,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
URL_API_INTEGRATION = {
"url": "https://dashboard.airthings.com/integrations/api-integration"
}
class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Airthings."""
@@ -41,7 +37,11 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders=URL_API_INTEGRATION,
description_placeholders={
"url": (
"https://dashboard.airthings.com/integrations/api-integration"
),
},
)
errors = {}
@@ -65,8 +65,5 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title="Airthings", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders=URL_API_INTEGRATION,
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -4,9 +4,9 @@
"user": {
"data": {
"id": "ID",
"secret": "Secret"
},
"description": "Log in at {url} to find your credentials"
"secret": "Secret",
"description": "Login at {url} to find your credentials"
}
}
},
"error": {

View File

@@ -171,7 +171,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="no_devices_found")
titles = {
address: get_name(discovery.device)
address: discovery.device.name
for (address, discovery) in self._discovered_devices.items()
}
return self.async_show_form(

View File

@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling",
"requirements": ["airthings-ble==1.1.1"]
"requirements": ["airthings-ble==0.9.2"]
}

View File

@@ -6,9 +6,6 @@
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
},
"data_description": {
"address": "The Airthings devices discovered via Bluetooth."
}
},
"bluetooth_confirm": {

View File

@@ -2,14 +2,17 @@
from airtouch4pyapi import AirTouch
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import AirTouch4ConfigEntry, AirtouchDataUpdateCoordinator
from .coordinator import AirtouchDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE]
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) -> bool:
"""Set up AirTouch4 from a config entry."""
@@ -19,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirTouch4ConfigEntry) ->
info = airtouch.GetAcs()
if not info:
raise ConfigEntryNotReady
coordinator = AirtouchDataUpdateCoordinator(hass, entry, airtouch)
coordinator = AirtouchDataUpdateCoordinator(hass, airtouch)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -2,34 +2,26 @@
import logging
from airtouch4pyapi import AirTouch
from airtouch4pyapi.airtouch import AirTouchStatus
from homeassistant.components.climate import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type AirTouch4ConfigEntry = ConfigEntry[AirtouchDataUpdateCoordinator]
class AirtouchDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching Airtouch data."""
def __init__(
self, hass: HomeAssistant, entry: AirTouch4ConfigEntry, airtouch: AirTouch
) -> None:
def __init__(self, hass, airtouch):
"""Initialize global Airtouch data updater."""
self.airtouch = airtouch
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.2.8"]
"requirements": ["aioamazondevices==6.2.7"]
}

View File

@@ -629,6 +629,7 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
devices_info.append(
{
"entities": [],
"entry_type": device_entry.entry_type,
"has_configuration_url": device_entry.configuration_url is not None,
"hw_version": device_entry.hw_version,
@@ -637,7 +638,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
"model_id": device_entry.model_id,
"sw_version": device_entry.sw_version,
"via_device": device_entry.via_device_id,
"entities": [],
}
)

View File

@@ -26,6 +26,9 @@ async def async_setup_entry(
if CONF_HOST in config_entry.data:
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
config_entry.async_on_unload(
config_entry.add_update_listener(_async_update_listener)
)
else:
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
@@ -33,11 +36,6 @@ async def async_setup_entry(
config_entry.runtime_data = coordinator
if CONF_HOST in config_entry.data:
config_entry.async_on_unload(
config_entry.add_update_listener(_async_update_listener)
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View File

@@ -19,8 +19,8 @@
"bleak-retry-connector==4.4.3",
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.3",
"dbus-fast==2.44.5",
"habluetooth==5.7.0"
"bluetooth-data-tools==1.28.2",
"dbus-fast==2.44.3",
"habluetooth==5.6.4"
]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pycync==0.4.1"]
"requirements": ["pycync==0.4.0"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.17.1"],
"requirements": ["pydaikin==2.16.0"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@@ -126,7 +126,7 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity):
self._attr_translation_key = "button"
self._attr_translation_placeholders = {"key": str(key)}
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the binary sensor state."""
if (
message[0] == self._remote_control_property.element_uid

View File

@@ -48,6 +48,7 @@ class DevoloDeviceEntity(Entity):
)
self.subscriber: Subscriber | None = None
self.sync_callback = self._sync
self._value: float
@@ -68,7 +69,7 @@ class DevoloDeviceEntity(Entity):
self._device_instance.uid, self.subscriber
)
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the state."""
if message[0] == self._attr_unique_id:
self._value = message[1]

View File

@@ -185,7 +185,7 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
"""
return f"{self._attr_unique_id}_{self._sensor_type}"
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the consumption sensor state."""
if message[0] == self._attr_unique_id:
self._value = getattr(

View File

@@ -13,3 +13,8 @@ class Subscriber:
"""Initiate the subscriber."""
self.name = name
self.callback = callback
def update(self, message: str) -> None:
"""Trigger hass to update the device."""
_LOGGER.debug('%s got message "%s"', self.name, message)
self.callback(message)

View File

@@ -64,7 +64,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity):
"""Switch off the device."""
self._binary_switch_property.set(state=False)
def sync_callback(self, message: tuple) -> None:
def _sync(self, message: tuple) -> None:
"""Update the binary switch state and consumption."""
if message[0].startswith("devolo.BinarySwitch"):
self._attr_is_on = self._device_instance.binary_switch_property[

View File

@@ -17,6 +17,6 @@
"requirements": [
"aiodhcpwatcher==1.2.1",
"aiodiscover==2.7.1",
"cached-ipaddress==1.0.1"
"cached-ipaddress==0.10.0"
]
}

View File

@@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.0"],
"requirements": ["pyenphase==2.3.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==41.12.0",
"aioesphomeapi==41.11.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
"bleak-esphome==3.3.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -85,48 +84,6 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth when Firefly III API authentication fails."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth: ask for a new API key and validate."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
try:
await _validate_input(
self.hass,
data={
**reauth_entry.data,
CONF_API_KEY: user_input[CONF_API_KEY],
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -18,7 +18,7 @@ from pyfirefly.models import Account, Bill, Budget, Category, Currency
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -72,7 +72,7 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
try:
await self.firefly.get_about()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
@@ -109,7 +109,7 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
budgets = await self.firefly.get_budgets()
bills = await self.firefly.get_bills()
except FireflyAuthenticationError as err:
raise ConfigEntryAuthFailed(
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},

View File

@@ -100,6 +100,15 @@ class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
"""Return the state of the sensor."""
return self._account.attributes.current_balance
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return extra state attributes for the account entity."""
return {
"account_role": self._account.attributes.account_role or "",
"account_type": self._account.attributes.type or "",
"current_balance": str(self._account.attributes.current_balance or ""),
}
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III category."""

View File

@@ -13,15 +13,6 @@
"verify_ssl": "Verify the SSL certificate of the Firefly instance"
},
"description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The new API access token for authenticating with Firefly III"
},
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
}
},
"error": {
@@ -31,8 +22,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {

View File

@@ -452,10 +452,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.http.app.router.register_resource(IndexView(repo_path, hass))
async_register_built_in_panel(hass, "light")
async_register_built_in_panel(hass, "security")
async_register_built_in_panel(hass, "climate")
async_register_built_in_panel(hass, "profile")
async_register_built_in_panel(

View File

@@ -59,7 +59,7 @@
},
"media_player": {
"name": "Media player entity",
"description": "Name(s) of media player entities to play the Google Assistant's audio response on. This does not target the device for the command itself."
"description": "Name(s) of media player entities to play response on."
}
}
}

View File

@@ -22,7 +22,6 @@ from homeassistant.exceptions import (
from homeassistant.helpers import config_entry_oauth2_flow
_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
_UPLOAD_MAX_RETRIES = 20
_LOGGER = logging.getLogger(__name__)
@@ -151,7 +150,6 @@ class DriveClient:
backup_metadata,
open_stream,
backup.size,
max_retries=_UPLOAD_MAX_RETRIES,
timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT),
)
_LOGGER.debug(

View File

@@ -620,13 +620,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
def create_generate_content_config(self) -> GenerateContentConfig:
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
thinking_config: ThinkingConfig | None = None
if model.startswith("models/gemini-2.5") and not model.endswith(
("tts", "image", "image-preview")
):
thinking_config = ThinkingConfig(include_thoughts=True)
return GenerateContentConfig(
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
@@ -659,7 +652,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
),
),
],
thinking_config=thinking_config,
thinking_config=ThinkingConfig(include_thoughts=True),
)

View File

@@ -22,7 +22,6 @@ from google.protobuf import timestamp_pb2
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
@@ -92,16 +91,6 @@ def convert_time(time_str: str) -> timestamp_pb2.Timestamp | None:
return timestamp
SENSOR_DESCRIPTIONS = [
SensorEntityDescription(
key="duration",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
)
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -116,20 +105,20 @@ async def async_setup_entry(
client_options = ClientOptions(api_key=api_key)
client = RoutesAsyncClient(client_options=client_options)
sensors = [
GoogleTravelTimeSensor(
config_entry, name, api_key, origin, destination, client, sensor_description
)
for sensor_description in SENSOR_DESCRIPTIONS
]
sensor = GoogleTravelTimeSensor(
config_entry, name, api_key, origin, destination, client
)
async_add_entities(sensors, False)
async_add_entities([sensor], False)
class GoogleTravelTimeSensor(SensorEntity):
"""Representation of a Google travel time sensor."""
_attr_attribution = ATTRIBUTION
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_device_class = SensorDeviceClass.DURATION
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self,
@@ -139,10 +128,8 @@ class GoogleTravelTimeSensor(SensorEntity):
origin: str,
destination: str,
client: RoutesAsyncClient,
sensor_description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self.entity_description = sensor_description
self._attr_name = name
self._attr_unique_id = config_entry.entry_id
self._attr_device_info = DeviceInfo(

View File

@@ -1,18 +1,14 @@
"""The Growatt server PV inverter sensor integration."""
from collections.abc import Mapping
import logging
import growattServer
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.exceptions import ConfigEntryError
from .const import (
AUTH_API_TOKEN,
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_PLANT_ID,
DEFAULT_URL,
@@ -23,110 +19,36 @@ from .const import (
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
_LOGGER = logging.getLogger(__name__)
def get_device_list_classic(
def get_device_list(
api: growattServer.GrowattApi, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
"""Retrieve the device list for the selected plant."""
plant_id = config[CONF_PLANT_ID]
# Log in to api and fetch first plant if no plant id is defined.
try:
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
# DEBUG: Log the actual response structure
except Exception as ex:
_LOGGER.error("DEBUG - Login response: %s", login_response)
raise ConfigEntryError(
f"Error communicating with Growatt API during login: {ex}"
) from ex
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
_LOGGER.debug("Growatt login failed: %s", msg)
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed("Username, Password or URL may be incorrect!")
raise ConfigEntryError(f"Growatt login failed: {msg}")
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
raise ConfigEntryError("Username, Password or URL may be incorrect!")
user_id = login_response["user"]["id"]
if plant_id == DEFAULT_PLANT_ID:
try:
plant_info = api.plant_list(user_id)
except Exception as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during plant list: {ex}"
) from ex
if not plant_info or "data" not in plant_info or not plant_info["data"]:
raise ConfigEntryError("No plants found for this account.")
plant_info = api.plant_list(user_id)
plant_id = plant_info["data"][0]["plantId"]
# Get a list of devices for specified plant to add sensors for.
try:
devices = api.device_list(plant_id)
except Exception as ex:
raise ConfigEntryError(
f"Error communicating with Growatt API during device list: {ex}"
) from ex
devices = api.device_list(plant_id)
return devices, plant_id
def get_device_list_v1(
api, config: Mapping[str, str]
) -> tuple[list[dict[str, str]], str]:
"""Device list logic for Open API V1.
Note: Plant selection (including auto-selection if only one plant exists)
is handled in the config flow before this function is called. This function
only fetches devices for the already-selected plant_id.
"""
plant_id = config[CONF_PLANT_ID]
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
raise ConfigEntryError(
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
) 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": "min",
}
for device in devices
if device.get("type") == 7
]
for device in devices:
if device.get("type") != 7:
_LOGGER.warning(
"Device %s with type %s not supported in Open API V1, skipping",
device.get("device_sn", ""),
device.get("type"),
)
return supported_devices, plant_id
def get_device_list(
api, config: Mapping[str, str], api_version: str
) -> tuple[list[dict[str, str]], str]:
"""Dispatch to correct device list logic based on API version."""
if api_version == "v1":
return get_device_list_v1(api, config)
if api_version == "classic":
return get_device_list_classic(api, config)
raise ConfigEntryError(f"Unknown API version: {api_version}")
async def async_setup_entry(
hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> bool:
"""Set up Growatt from a config entry."""
config = config_entry.data
username = config[CONF_USERNAME]
url = config.get(CONF_URL, DEFAULT_URL)
# If the URL has been deprecated then change to the default instead
@@ -136,24 +58,11 @@ async def async_setup_entry(
new_data[CONF_URL] = url
hass.config_entries.async_update_entry(config_entry, data=new_data)
# Determine API version
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
api_version = "v1"
token = config[CONF_TOKEN]
api = growattServer.OpenApiV1(token=token)
elif config.get(CONF_AUTH_TYPE) == AUTH_PASSWORD:
api_version = "classic"
username = config[CONF_USERNAME]
api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=username
)
api.server_url = url
else:
raise ConfigEntryError("Unknown authentication type in config entry.")
# Initialise the library with the username & a random id each time it is started
api = growattServer.GrowattApi(add_random_user_id=True, agent_identifier=username)
api.server_url = url
devices, plant_id = await hass.async_add_executor_job(
get_device_list, api, config, api_version
)
devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config)
# Create a coordinator for the total sensors
total_coordinator = GrowattCoordinator(
@@ -166,7 +75,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"]
if device["deviceType"] in ["inverter", "tlx", "storage", "mix"]
}
# Perform the first refresh for the total coordinator

View File

@@ -1,38 +1,22 @@
"""Config flow for growatt server integration."""
import logging
from typing import Any
import growattServer
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_URL,
CONF_USERNAME,
)
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import callback
from .const import (
ABORT_NO_PLANTS,
AUTH_API_TOKEN,
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
DEFAULT_URL,
DOMAIN,
ERROR_CANNOT_CONNECT,
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS,
)
_LOGGER = logging.getLogger(__name__)
class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow class."""
@@ -43,98 +27,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialise growatt server flow."""
self.user_id: str | None = None
self.user_id = None
self.data: dict[str, Any] = {}
self.auth_type: str | None = None
self.plants: list[dict[str, Any]] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the start of the config flow."""
return self.async_show_menu(
step_id="user",
menu_options=["password_auth", "token_auth"],
)
async def async_step_password_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle username/password authentication."""
if user_input is None:
return self._async_show_password_form()
self.auth_type = AUTH_PASSWORD
# Traditional username/password authentication
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = user_input[CONF_URL]
try:
login_response = await self.hass.async_add_executor_job(
self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt API login: %s", ex)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error("Invalid response format during login: %s", ex)
return self._async_show_password_form({"base": ERROR_CANNOT_CONNECT})
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
return self._async_show_password_form({"base": ERROR_INVALID_AUTH})
self.user_id = login_response["user"]["id"]
self.data = user_input
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
async def async_step_token_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle API token authentication."""
if user_input is None:
return self._async_show_token_form()
self.auth_type = AUTH_API_TOKEN
# Using token authentication
token = user_input[CONF_TOKEN]
self.api = growattServer.OpenApiV1(token=token)
# Verify token by fetching plant list
try:
plant_response = await self.hass.async_add_executor_job(self.api.plant_list)
self.plants = plant_response.get("plants", [])
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt V1 API plant list: %s", ex)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
except growattServer.GrowattV1ApiError as e:
_LOGGER.error(
"Growatt V1 API error: %s (Code: %s)",
e.error_msg or str(e),
getattr(e, "error_code", None),
)
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error(
"Invalid response format during Growatt V1 API plant list: %s", ex
)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
self.data = user_input
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
@callback
def _async_show_password_form(
self, errors: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the username/password form to the user."""
def _async_show_user_form(self, errors=None):
"""Show the form to the user."""
data_schema = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
@@ -144,87 +42,58 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="password_auth", data_schema=data_schema, errors=errors
step_id="user", data_schema=data_schema, errors=errors
)
@callback
def _async_show_token_form(
self, errors: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the API token form to the user."""
data_schema = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
}
"""Handle the start of the config flow."""
if not user_input:
return self._async_show_user_form()
# Initialise the library with the username & a random id each time it is started
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = user_input[CONF_URL]
login_response = await self.hass.async_add_executor_job(
self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
return self.async_show_form(
step_id="token_auth",
data_schema=data_schema,
errors=errors,
)
if (
not login_response["success"]
and login_response["msg"] == LOGIN_INVALID_AUTH_CODE
):
return self._async_show_user_form({"base": "invalid_auth"})
self.user_id = login_response["user"]["id"]
self.data = user_input
return await self.async_step_plant()
async def async_step_plant(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle adding a "plant" to Home Assistant."""
if self.auth_type == AUTH_API_TOKEN:
# Using V1 API with token
if not self.plants:
return self.async_abort(reason=ABORT_NO_PLANTS)
plant_info = await self.hass.async_add_executor_job(
self.api.plant_list, self.user_id
)
# Create dictionary of plant_id -> name
plant_dict = {
str(plant["plant_id"]): plant.get("name", "Unknown Plant")
for plant in self.plants
}
if not plant_info["data"]:
return self.async_abort(reason="no_plants")
if user_input is None and len(plant_dict) > 1:
data_schema = vol.Schema(
{vol.Required(CONF_PLANT_ID): vol.In(plant_dict)}
)
return self.async_show_form(step_id="plant", data_schema=data_schema)
plants = {plant["plantId"]: plant["plantName"] for plant in plant_info["data"]}
if user_input is None:
# Single plant => mark it as selected
user_input = {CONF_PLANT_ID: list(plant_dict.keys())[0]}
if user_input is None and len(plant_info["data"]) > 1:
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
user_input[CONF_NAME] = plant_dict[user_input[CONF_PLANT_ID]]
return self.async_show_form(step_id="plant", data_schema=data_schema)
else:
# Traditional API
try:
plant_info = await self.hass.async_add_executor_job(
self.api.plant_list, self.user_id
)
except requests.exceptions.RequestException as ex:
_LOGGER.error("Network error during Growatt API plant list: %s", ex)
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
# Access plant_info["data"] - validate response structure
if not isinstance(plant_info, dict) or "data" not in plant_info:
_LOGGER.error(
"Invalid response format during plant list: missing 'data' key"
)
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
plant_data = plant_info["data"]
if not plant_data:
return self.async_abort(reason=ABORT_NO_PLANTS)
plants = {plant["plantId"]: plant["plantName"] for plant in plant_data}
if user_input is None and len(plant_data) > 1:
data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
return self.async_show_form(step_id="plant", data_schema=data_schema)
if user_input is None:
# single plant => mark it as selected
user_input = {CONF_PLANT_ID: plant_data[0]["plantId"]}
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
if user_input is None:
# single plant => mark it as selected
user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]}
user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
await self.async_set_unique_id(user_input[CONF_PLANT_ID])
self._abort_if_unique_id_configured()
self.data.update(user_input)

View File

@@ -4,16 +4,6 @@ from homeassistant.const import Platform
CONF_PLANT_ID = "plant_id"
# API key support
CONF_API_KEY = "api_key"
# Auth types for config flow
AUTH_PASSWORD = "password"
AUTH_API_TOKEN = "api_token"
CONF_AUTH_TYPE = "auth_type"
DEFAULT_AUTH_TYPE = AUTH_PASSWORD
DEFAULT_PLANT_ID = "0"
DEFAULT_NAME = "Growatt"
@@ -39,10 +29,3 @@ DOMAIN = "growatt_server"
PLATFORMS = [Platform.SENSOR]
LOGIN_INVALID_AUTH_CODE = "502"
# Config flow error types (also used as abort reasons)
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
ERROR_INVALID_AUTH = "invalid_auth"
# Config flow abort reasons
ABORT_NO_PLANTS = "no_plants"

View File

@@ -40,30 +40,22 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
plant_id: str,
) -> None:
"""Initialize the coordinator."""
self.api_version = (
"v1" if config_entry.data.get("auth_type") == "api_token" else "classic"
self.username = config_entry.data[CONF_USERNAME]
self.password = config_entry.data[CONF_PASSWORD]
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=self.username
)
# Set server URL
self.api.server_url = self.url
self.device_id = device_id
self.device_type = device_type
self.plant_id = plant_id
self.previous_values: dict[str, Any] = {}
if self.api_version == "v1":
self.username = None
self.password = None
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.token = config_entry.data["token"]
self.api = growattServer.OpenApiV1(token=self.token)
elif self.api_version == "classic":
self.username = config_entry.data.get(CONF_USERNAME)
self.password = config_entry.data[CONF_PASSWORD]
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=self.username
)
self.api.server_url = self.url
else:
raise ValueError(f"Unknown API version: {self.api_version}")
# Initialize previous_values to store historical data
self.previous_values: dict[str, Any] = {}
super().__init__(
hass,
@@ -77,54 +69,21 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Update data via library synchronously."""
_LOGGER.debug("Updating data for %s (%s)", self.device_id, self.device_type)
# login only required for classic API
if self.api_version == "classic":
self.api.login(self.username, self.password)
# Login in to the Growatt server
self.api.login(self.username, self.password)
if self.device_type == "total":
if self.api_version == "v1":
# The V1 Plant APIs do not provide the same information as the classic plant_info() API
# More specifically:
# 1. There is no monetary information to be found, so today and lifetime money is not available
# 2. There is no nominal power, this is provided by inverter min_energy()
# This means, for the total coordinator we can only fetch and map the following:
# todayEnergy -> today_energy
# totalEnergy -> total_energy
# invTodayPpv -> current_power
total_info = self.api.plant_energy_overview(self.plant_id)
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
else:
# Classic API: use plant_info as before
total_info = self.api.plant_info(self.device_id)
del total_info["deviceList"]
plant_money_text, currency = total_info["plantMoneyText"].split("/")
total_info["plantMoneyText"] = plant_money_text
total_info["currency"] = currency
_LOGGER.debug("Total info for plant %s: %r", self.plant_id, total_info)
total_info = self.api.plant_info(self.device_id)
del total_info["deviceList"]
plant_money_text, currency = total_info["plantMoneyText"].split("/")
total_info["plantMoneyText"] = plant_money_text
total_info["currency"] = currency
self.data = total_info
elif self.device_type == "inverter":
self.data = self.api.inverter_detail(self.device_id)
elif self.device_type == "min":
# Open API V1: min device
try:
min_details = self.api.min_detail(self.device_id)
min_settings = self.api.min_settings(self.device_id)
min_energy = self.api.min_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
_LOGGER.error(
"Error fetching min device data for %s: %s", self.device_id, err
)
raise UpdateFailed(f"Error fetching min device data: {err}") from err
min_info = {**min_details, **min_settings, **min_energy}
self.data = min_info
_LOGGER.debug("min_info for device %s: %r", self.device_id, min_info)
elif self.device_type == "tlx":
tlx_info = self.api.tlx_detail(self.device_id)
self.data = tlx_info["data"]
_LOGGER.debug("tlx_info for device %s: %r", self.device_id, tlx_info)
elif self.device_type == "storage":
storage_info_detail = self.api.storage_params(self.device_id)
storage_energy_overview = self.api.storage_energy_overview(

View File

@@ -51,7 +51,7 @@ async def async_setup_entry(
sensor_descriptions: list = []
if device_coordinator.device_type == "inverter":
sensor_descriptions = list(INVERTER_SENSOR_TYPES)
elif device_coordinator.device_type in ("tlx", "min"):
elif device_coordinator.device_type == "tlx":
sensor_descriptions = list(TLX_SENSOR_TYPES)
elif device_coordinator.device_type == "storage":
sensor_descriptions = list(STORAGE_SENSOR_TYPES)

View File

@@ -2,42 +2,26 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account"
},
"error": {
"invalid_auth": "Authentication failed. Please check your credentials and try again.",
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again."
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"user": {
"title": "Choose authentication method",
"description": "Note: API 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/TLX only)"
}
},
"password_auth": {
"title": "Enter your Growatt login credentials",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"url": "[%key:common::config_flow::data::url%]"
}
},
"token_auth": {
"title": "Enter your API token",
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"data": {
"token": "API Token"
}
},
"plant": {
"data": {
"plant_id": "Plant"
},
"title": "Select your plant"
},
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"url": "[%key:common::config_flow::data::url%]"
},
"title": "Enter your Growatt information"
}
}
},

View File

@@ -174,9 +174,6 @@
},
"collected_items": {
"default": "mdi:sack"
},
"last_checkin": {
"default": "mdi:login-variant"
}
},
"switch": {

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
import logging
from typing import Any
@@ -54,7 +53,7 @@ PARALLEL_UPDATES = 1
class HabiticaSensorEntityDescription(SensorEntityDescription):
"""Habitica Sensor Description."""
value_fn: Callable[[UserData, ContentData], StateType | datetime]
value_fn: Callable[[UserData, ContentData], StateType]
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
None
)
@@ -115,7 +114,6 @@ class HabiticaSensorEntity(StrEnum):
COLLECTED_ITEMS = "collected_items"
BOSS_RAGE = "boss_rage"
BOSS_RAGE_LIMIT = "boss_rage_limit"
LAST_CHECKIN = "last_checkin"
SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
@@ -286,16 +284,6 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
translation_key=HabiticaSensorEntity.PENDING_QUEST_ITEMS,
value_fn=pending_quest_items,
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.LAST_CHECKIN,
translation_key=HabiticaSensorEntity.LAST_CHECKIN,
value_fn=(
lambda user, _: dt_util.as_local(last)
if (last := user.auth.timestamps.loggedin)
else None
),
device_class=SensorDeviceClass.TIMESTAMP,
),
)
@@ -411,7 +399,7 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
entity_description: HabiticaSensorEntityDescription
@property
def native_value(self) -> StateType | datetime:
def native_value(self) -> StateType:
"""Return the state of the device."""
return self.entity_description.value_fn(
@@ -454,7 +442,7 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity):
entity_description: HabiticaPartySensorEntityDescription
@property
def native_value(self) -> StateType | datetime:
def native_value(self) -> StateType:
"""Return the state of the device."""
return self.entity_description.value_fn(

View File

@@ -290,9 +290,6 @@
}
}
},
"last_checkin": {
"name": "Last check-in"
},
"health": {
"name": "Health",
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"

View File

@@ -73,6 +73,7 @@ class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
try:
await supervisor_client.addons.start_addon(self._addon_slug)
except SupervisorError as err:
_LOGGER.error("Failed to start addon %s: %s", self._addon_slug, err)
raise HomeAssistantError(err) from err
await self.coordinator.force_addon_info_data_refresh(self._addon_slug)

View File

@@ -67,7 +67,11 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
# `rts_dtr` targets older adapters, `baudrate` works for newer ones. The reason we
# try them in this order is that on older adapters `baudrate` entered the ESP32-S3
# bootloader instead of the MG24 bootloader.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None

View File

@@ -157,7 +157,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity."""
bootloader_reset_methods = [ResetTarget.RTS_DTR]
bootloader_reset_methods = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
def __init__(
self,

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.0.35",
"universal-silabs-flasher==0.0.34",
"ha-silabs-firmware-client==0.2.0"
]
}

View File

@@ -10,7 +10,7 @@
"loggers": ["pyhap"],
"requirements": [
"HAP-python==5.0.0",
"fnv-hash-fast==1.6.0",
"fnv-hash-fast==1.5.0",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.19"],
"requirements": ["aiohomekit==3.2.18"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}

View File

@@ -8,16 +8,13 @@ from idasen_ha import Desk
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator]
UPDATE_DEBOUNCE_TIME = 0.2
class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
"""Class to manage updates for the Idasen Desk."""
@@ -36,22 +33,9 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
hass, _LOGGER, config_entry=config_entry, name=config_entry.title
)
self.address = address
self.desk = Desk(self._async_handle_update)
self._expected_connected = False
self._height: int | None = None
@callback
def async_update_data() -> None:
self.async_set_updated_data(self._height)
self._debouncer = Debouncer(
hass=self.hass,
logger=_LOGGER,
cooldown=UPDATE_DEBOUNCE_TIME,
immediate=True,
function=async_update_data,
)
self.desk = Desk(self.async_set_updated_data)
async def async_connect(self) -> bool:
"""Connect to desk."""
@@ -76,9 +60,3 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
"""Ensure that the desk is connected if that is the expected state."""
if self._expected_connected:
await self.async_connect()
@callback
def _async_handle_update(self, height: int | None) -> None:
"""Handle an update from the desk."""
self._height = height
self._debouncer.async_schedule_call()

View File

@@ -147,9 +147,8 @@ class KrakenData:
def _get_websocket_name_asset_pairs(self) -> str:
return ",".join(
pair
self.tradable_asset_pairs[tracked_pair]
for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS]
if (pair := self.tradable_asset_pairs.get(tracked_pair)) is not None
)
def set_update_interval(self, update_interval: int) -> None:

View File

@@ -156,7 +156,7 @@ async def async_setup_entry(
for description in SENSOR_TYPES
]
)
async_add_entities(entities)
async_add_entities(entities, True)
_async_add_kraken_sensors(config_entry.options[CONF_TRACKED_ASSET_PAIRS])

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.28.3", "ld2410-ble==0.1.1"]
"requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"]
}

View File

@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.28.3", "led-ble==1.1.7"]
"requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"]
}

View File

@@ -196,11 +196,11 @@ class LocalTodoListEntity(TodoListEntity):
item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)}
if uid not in item_idx:
raise HomeAssistantError(
f"Item '{uid}' not found in todo list {self.entity_id}"
"Item '{uid}' not found in todo list {self.entity_id}"
)
if previous_uid and previous_uid not in item_idx:
raise HomeAssistantError(
f"Item '{previous_uid}' not found in todo list {self.entity_id}"
"Item '{previous_uid}' not found in todo list {self.entity_id}"
)
dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0
src_idx = item_idx[uid]

View File

@@ -1,64 +0,0 @@
"""The Lunatone integration."""
from typing import Final
from lunatone_rest_api_client import Auth, Devices, Info
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import (
LunatoneConfigEntry,
LunatoneData,
LunatoneDevicesDataUpdateCoordinator,
LunatoneInfoDataUpdateCoordinator,
)
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Set up Lunatone from a config entry."""
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
info_api = Info(auth_api)
devices_api = Devices(auth_api)
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
await coordinator_info.async_config_entry_first_refresh()
if info_api.serial_number is None:
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="missing_device_info"
)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, str(info_api.serial_number))},
name=info_api.name,
manufacturer="Lunatone",
sw_version=info_api.version,
hw_version=info_api.data.device.pcb,
configuration_url=entry.data[CONF_URL],
serial_number=str(info_api.serial_number),
model_id=(
f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
),
)
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
await coordinator_devices.async_config_entry_first_refresh()
entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,83 +0,0 @@
"""Config flow for Lunatone."""
from typing import Any, Final
import aiohttp
from lunatone_rest_api_client import Auth, Info
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA: Final[vol.Schema] = vol.Schema(
{vol.Required(CONF_URL, default="http://"): cv.string},
)
def compose_title(name: str | None, serial_number: int) -> str:
"""Compose a title string from a given name and serial number."""
return f"{name or 'DALI Gateway'} {serial_number}"
class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
"""Lunatone config flow."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_URL]
data = {CONF_URL: url}
self._async_abort_entries_match(data)
auth_api = Auth(
session=async_get_clientsession(self.hass),
base_url=url,
)
info_api = Info(auth_api)
try:
await info_api.async_update()
except aiohttp.InvalidUrlClientError:
errors["base"] = "invalid_url"
except aiohttp.ClientConnectionError:
errors["base"] = "cannot_connect"
else:
if info_api.data is None or info_api.serial_number is None:
errors["base"] = "missing_device_info"
else:
await self.async_set_unique_id(str(info_api.serial_number))
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=data,
title=compose_title(info_api.name, info_api.serial_number),
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=compose_title(info_api.name, info_api.serial_number),
data={CONF_URL: url},
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
return await self.async_step_user(user_input)

View File

@@ -1,5 +0,0 @@
"""Constants for the Lunatone integration."""
from typing import Final
DOMAIN: Final = "lunatone"

View File

@@ -1,101 +0,0 @@
"""Coordinator for handling data fetching and updates."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
import aiohttp
from lunatone_rest_api_client import Device, Devices, Info
from lunatone_rest_api_client.models import InfoData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
@dataclass
class LunatoneData:
"""Data for Lunatone integration."""
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
type LunatoneConfigEntry = ConfigEntry[LunatoneData]
class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
"""Data update coordinator for Lunatone info."""
config_entry: LunatoneConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: LunatoneConfigEntry, info_api: Info
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-info",
always_update=False,
)
self.info_api = info_api
async def _async_update_data(self) -> InfoData:
"""Update info data."""
try:
await self.info_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve info data from Lunatone REST API"
) from ex
if self.info_api.data is None:
raise UpdateFailed("Did not receive info data from Lunatone REST API")
return self.info_api.data
class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Device]]):
"""Data update coordinator for Lunatone devices."""
config_entry: LunatoneConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
devices_api: Devices,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-devices",
always_update=False,
update_interval=DEFAULT_DEVICES_SCAN_INTERVAL,
)
self.devices_api = devices_api
async def _async_update_data(self) -> dict[int, Device]:
"""Update devices data."""
try:
await self.devices_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve devices data from Lunatone REST API"
) from ex
if self.devices_api.data is None:
raise UpdateFailed("Did not receive devices data from Lunatone REST API")
return {device.id: device for device in self.devices_api.devices}

View File

@@ -1,103 +0,0 @@
"""Platform for Lunatone light integration."""
from __future__ import annotations
import asyncio
from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
PARALLEL_UPDATES = 0
STATUS_UPDATE_DELAY = 0.04
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lunatone Light platform."""
coordinator_info = config_entry.runtime_data.coordinator_info
coordinator_devices = config_entry.runtime_data.coordinator_devices
async_add_entities(
[
LunatoneLight(
coordinator_devices, device_id, coordinator_info.data.device.serial
)
for device_id in coordinator_devices.data
]
)
class LunatoneLight(
CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
):
"""Representation of a Lunatone light."""
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
coordinator: LunatoneDevicesDataUpdateCoordinator,
device_id: int,
interface_serial_number: int,
) -> None:
"""Initialize a LunatoneLight."""
super().__init__(coordinator=coordinator)
self._device_id = device_id
self._interface_serial_number = interface_serial_number
self._device = self.coordinator.data.get(self._device_id)
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
assert self.unique_id
name = self._device.name if self._device is not None else None
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=name,
via_device=(DOMAIN, str(self._interface_serial_number)),
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._device is not None
@property
def is_on(self) -> bool:
"""Return True if light is on."""
return self._device is not None and self._device.is_on
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._device = self.coordinator.data.get(self._device_id)
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
assert self._device
await self._device.switch_on()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
assert self._device
await self._device.switch_off()
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()

View File

@@ -1,11 +0,0 @@
{
"domain": "lunatone",
"name": "Lunatone",
"codeowners": ["@MoonDevLT"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lunatone",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.4.8"]
}

View File

@@ -1,82 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules:
status: exempt
comment: |
This integration has only one platform which uses a coordinator.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this 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: done
# Silver
action-exceptions:
status: exempt
comment: no actions
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: todo
comment: Discovery not yet supported
discovery:
status: todo
comment: Discovery not yet supported
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,36 +0,0 @@
{
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"user": {
"description": "Connect to the API of your Lunatone DALI IoT Gateway.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The URL of the Lunatone gateway device."
}
},
"reconfigure": {
"description": "Update the URL.",
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "[%key:component::lunatone::config::step::user::data_description::url%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
"missing_device_info": "Failed to read device information. Check the network connection of the device"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
}

View File

@@ -88,17 +88,6 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterBinarySensor,
required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="ThermostatOccupancySensor",
device_class=BinarySensorDeviceClass.OCCUPANCY,
# The first bit = if occupied
device_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.Thermostat.Attributes.Occupancy,),
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(

View File

@@ -30,7 +30,6 @@ from .entity import MatterEntity
from .helpers import get_matter
from .models import MatterDiscoverySchema
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
HVAC_SYSTEM_MODE_MAP = {
HVACMode.OFF: 0,
@@ -262,18 +261,6 @@ class MatterClimate(MatterEntity, ClimateEntity):
self._attr_current_temperature = self._get_temperature_in_degrees(
clusters.Thermostat.Attributes.LocalTemperature
)
self._attr_current_humidity = (
int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR
if (
raw_measured_humidity := self.get_matter_attribute_value(
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue
)
)
is not None
else None
)
if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False:
# special case: the appliance has a dedicated Power switch on the OnOff cluster
# if the mains power is off - treat it as if the HVAC mode is off
@@ -441,7 +428,6 @@ DISCOVERY_SCHEMAS = [
clusters.Thermostat.Attributes.TemperatureSetpointHold,
clusters.Thermostat.Attributes.UnoccupiedCoolingSetpoint,
clusters.Thermostat.Attributes.UnoccupiedHeatingSetpoint,
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
clusters.OnOff.Attributes.OnOff,
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),

View File

@@ -351,7 +351,6 @@ DISCOVERY_SCHEMAS = [
required_attributes=(
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
),
allow_multi=True, # also used for climate entity
),
MatterDiscoverySchema(
platform=Platform.SENSOR,

View File

@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.translation import async_get_cached_translations
from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
@@ -63,15 +62,12 @@ class MediaSourceItem:
async def async_browse(self) -> BrowseMediaSource:
"""Browse this item."""
if self.domain is None:
title = async_get_cached_translations(
self.hass, self.hass.config.language, "common", "media_source"
).get("component.media_source.common.sources_default", "Media Sources")
base = BrowseMediaSource(
domain=None,
identifier=None,
media_class=MediaClass.APP,
media_content_type=MediaType.APPS,
title=title,
title="Media Sources",
can_play=False,
can_expand=True,
children_media_class=MediaClass.APP,

View File

@@ -9,8 +9,5 @@
"unknown_media_source": {
"message": "Unknown media source: {domain}"
}
},
"common": {
"sources_default": "Media sources"
}
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
"requirements": ["millheater==0.13.1", "mill-local==0.3.0"]
}

View File

@@ -46,6 +46,14 @@ from homeassistant.components.light import (
VALID_COLOR_MODES,
valid_supported_color_modes,
)
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS,
NumberDeviceClass,
NumberMode,
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DEVICE_CLASS_UNITS,
@@ -79,6 +87,7 @@ from homeassistant.const import (
CONF_EFFECT,
CONF_ENTITY_CATEGORY,
CONF_HOST,
CONF_MODE,
CONF_NAME,
CONF_OPTIMISTIC,
CONF_PASSWORD,
@@ -211,7 +220,9 @@ from .const import (
CONF_IMAGE_TOPIC,
CONF_KEEPALIVE,
CONF_LAST_RESET_VALUE_TEMPLATE,
CONF_MAX,
CONF_MAX_KELVIN,
CONF_MIN,
CONF_MIN_KELVIN,
CONF_MODE_COMMAND_TEMPLATE,
CONF_MODE_COMMAND_TOPIC,
@@ -293,6 +304,7 @@ from .const import (
CONF_STATE_UNLOCKED,
CONF_STATE_UNLOCKING,
CONF_STATE_VALUE_TEMPLATE,
CONF_STEP,
CONF_SUGGESTED_DISPLAY_PRECISION,
CONF_SUPPORTED_COLOR_MODES,
CONF_SUPPORTED_FEATURES,
@@ -444,6 +456,8 @@ SUBENTRY_PLATFORMS = [
Platform.LIGHT,
Platform.LOCK,
Platform.NOTIFY,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -679,6 +693,24 @@ LIGHT_SCHEMA_SELECTOR = SelectSelector(
translation_key="light_schema",
)
)
MIN_MAX_SELECTOR = NumberSelector(NumberSelectorConfig(step=1e-3))
NUMBER_DEVICE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in NumberDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
# The number device classes are all shared with the sensor device classes
translation_key="device_class_sensor",
sort=True,
)
)
NUMBER_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[mode.value for mode in NumberMode],
mode=SelectSelectorMode.DROPDOWN,
translation_key="number_mode",
sort=True,
)
)
ON_COMMAND_TYPE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=VALUES_ON_COMMAND_TYPE,
@@ -726,6 +758,7 @@ SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
translation_key=CONF_STATE_CLASS,
)
)
STEP_SELECTOR = NumberSelector(NumberSelectorConfig(min=1e-3, step=1e-3))
SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[platform.value for platform in VALID_COLOR_MODES],
@@ -882,6 +915,23 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
)
@callback
def number_unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
"""Return a context based unit of measurement selector for number entities."""
if (
device_class := user_data.get(CONF_DEVICE_CLASS)
) is None or device_class not in NUMBER_DEVICE_CLASS_UNITS:
return TEXT_SELECTOR
return SelectSelector(
SelectSelectorConfig(
options=[str(uom) for uom in NUMBER_DEVICE_CLASS_UNITS[device_class]],
sort=True,
custom_value=True,
)
)
@callback
def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
"""Run validator, then return the unmodified input."""
@@ -1005,6 +1055,29 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
return errors
@callback
def validate_number_platform_config(config: dict[str, Any]) -> dict[str, str]:
"""Validate MQTT number configuration."""
errors: dict[str, Any] = {}
if (
CONF_MIN in config
and CONF_MAX in config
and config[CONF_MIN] > config[CONF_MAX]
):
errors[CONF_MIN] = "max_below_min"
errors[CONF_MAX] = "max_below_min"
if (
(device_class := config.get(CONF_DEVICE_CLASS)) is not None
and device_class in NUMBER_DEVICE_CLASS_UNITS
and config.get(CONF_UNIT_OF_MEASUREMENT)
not in NUMBER_DEVICE_CLASS_UNITS[device_class]
):
errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom"
return errors
@callback
def validate_sensor_platform_config(
config: dict[str, Any],
@@ -1067,6 +1140,8 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.LIGHT.value: validate_light_platform_config,
Platform.LOCK.value: None,
Platform.NOTIFY.value: None,
Platform.NUMBER.value: validate_number_platform_config,
Platform.SELECT: None,
Platform.SENSOR.value: validate_sensor_platform_config,
Platform.SWITCH.value: None,
}
@@ -1282,6 +1357,18 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
},
Platform.LOCK.value: {},
Platform.NOTIFY.value: {},
Platform.NUMBER: {
CONF_DEVICE_CLASS: PlatformField(
selector=NUMBER_DEVICE_CLASS_SELECTOR,
required=False,
),
CONF_UNIT_OF_MEASUREMENT: PlatformField(
selector=number_unit_of_measurement_selector,
required=False,
custom_filtering=True,
),
},
Platform.SELECT.value: {},
Platform.SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField(
selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
@@ -2966,6 +3053,86 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.NUMBER.value: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_MIN: PlatformField(
selector=MIN_MAX_SELECTOR,
required=True,
default=DEFAULT_MIN_VALUE,
),
CONF_MAX: PlatformField(
selector=MIN_MAX_SELECTOR,
required=True,
default=DEFAULT_MAX_VALUE,
),
CONF_STEP: PlatformField(
selector=STEP_SELECTOR,
required=True,
default=DEFAULT_STEP,
),
CONF_MODE: PlatformField(
selector=NUMBER_MODE_SELECTOR,
required=True,
default=NumberMode.AUTO.value,
),
CONF_PAYLOAD_RESET: PlatformField(
selector=TEXT_SELECTOR,
required=False,
default=DEFAULT_PAYLOAD_RESET,
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.SELECT.value: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_OPTIONS: PlatformField(selector=OPTIONS_SELECTOR, required=True),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.SENSOR.value: {
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,

View File

@@ -120,8 +120,10 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic"
CONF_HUMIDITY_MAX = "max_humidity"
CONF_HUMIDITY_MIN = "min_humidity"
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
CONF_MAX = "max"
CONF_MAX_KELVIN = "max_kelvin"
CONF_MAX_MIREDS = "max_mireds"
CONF_MIN = "min"
CONF_MIN_KELVIN = "min_kelvin"
CONF_MIN_MIREDS = "min_mireds"
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
@@ -196,6 +198,7 @@ CONF_STATE_OPENING = "state_opening"
CONF_STATE_STOPPED = "state_stopped"
CONF_STATE_UNLOCKED = "state_unlocked"
CONF_STATE_UNLOCKING = "state_unlocking"
CONF_STEP = "step"
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"

View File

@@ -188,10 +188,7 @@ class MqttLock(MqttEntity, LockEntity):
return
if payload == self._config[CONF_PAYLOAD_RESET]:
# Reset the state to `unknown`
self._attr_is_locked = self._attr_is_locking = None
self._attr_is_unlocking = None
self._attr_is_open = self._attr_is_opening = None
self._attr_is_jammed = None
self._attr_is_locked = None
elif payload in self._valid_states:
self._attr_is_locked = payload == self._config[CONF_STATE_LOCKED]
self._attr_is_locking = payload == self._config[CONF_STATE_LOCKING]

View File

@@ -37,8 +37,12 @@ from .config import MQTT_RW_SCHEMA
from .const import (
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_MAX,
CONF_MIN,
CONF_PAYLOAD_RESET,
CONF_STATE_TOPIC,
CONF_STEP,
DEFAULT_PAYLOAD_RESET,
)
from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import (
@@ -53,12 +57,7 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
CONF_MIN = "min"
CONF_MAX = "max"
CONF_STEP = "step"
DEFAULT_NAME = "MQTT Number"
DEFAULT_PAYLOAD_RESET = "None"
MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset(
{

View File

@@ -298,7 +298,7 @@
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)",
"supported_features": "The features that the entity supports.",
"temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.",
"unit_of_measurement": "Defines the unit of measurement of the sensor, if any."
"unit_of_measurement": "Defines the unit of measurement, if any."
},
"sections": {
"advanced_settings": {
@@ -334,6 +334,9 @@
"image_encoding": "Image encoding",
"image_topic": "Image topic",
"last_reset_value_template": "Last reset value template",
"max": "Maximum",
"min": "Minimum",
"mode": "Mode",
"modes": "Supported operation modes",
"mode_command_topic": "Operation mode command topic",
"mode_command_template": "Operation mode command template",
@@ -341,9 +344,11 @@
"mode_state_template": "Operation mode value template",
"on_command_type": "ON command type",
"optimistic": "Optimistic",
"options": "Set options",
"payload_off": "Payload \"off\"",
"payload_on": "Payload \"on\"",
"payload_press": "Payload \"press\"",
"payload_reset": "Payload \"reset\"",
"qos": "QoS",
"red_template": "Red template",
"retain": "Retain",
@@ -352,6 +357,7 @@
"state_template": "State template",
"state_topic": "State topic",
"state_value_template": "State value template",
"step": "Step",
"supported_color_modes": "Supported color modes",
"url_template": "URL template",
"url_topic": "URL topic",
@@ -376,6 +382,9 @@
"image_encoding": "Select the encoding of the received image data",
"image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)",
"last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)",
"max": "Maximum value. [Learn more.]({url}#max)",
"min": "Minimum value. [Learn more.]({url}#min)",
"mode": "Control how the number should be displayed in the UI. [Learn more.]({url}#mode)",
"modes": "A list of supported operation modes. [Learn more.]({url}#modes)",
"mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)",
"mode_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)",
@@ -383,9 +392,11 @@
"mode_state_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the operation mode state. [Learn more.]({url}#mode_state_template)",
"on_command_type": "Defines when the payload \"on\" is sent. Using \"Last\" (the default) will send any style (brightness, color, etc) topics first and then a payload \"on\" to the command topic. Using \"First\" will send the payload \"on\" and then any style topics. Using \"Brightness\" will only send brightness commands instead of the payload \"on\" to turn the light on.",
"optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)",
"options": "List of options that can be selected.",
"payload_off": "The payload that represents the \"off\" state.",
"payload_on": "The payload that represents the \"on\" state.",
"payload_press": "The payload to send when the button is triggered.",
"payload_reset": "The payload received at the state topic that resets the entity to an unknown state.",
"qos": "The QoS value a {platform} entity should use.",
"red_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.",
"retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.",
@@ -393,6 +404,7 @@
"state_on": "The incoming payload that represents the \"on\" state. Use only when the value that represents \"on\" state in the state topic is different from value that should be sent to the command topic to turn the device on.",
"state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.",
"state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)",
"step": "Step value. Smallest value 0.001.",
"supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)",
"url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)",
"url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)",
@@ -995,6 +1007,7 @@
"invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list",
"invalid_url": "Invalid URL",
"last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only",
"max_below_min": "Max value should be greater or equal to min value",
"max_below_min_humidity": "Max humidity value should be greater than min humidity value",
"max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value",
"max_below_min_temperature": "Max temperature value should be greater than min temperature value",
@@ -1294,6 +1307,13 @@
"template": "Template"
}
},
"number_mode": {
"options": {
"auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]",
"box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]",
"slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]"
}
},
"on_command_type": {
"options": {
"brightness": "Brightness",
@@ -1313,6 +1333,8 @@
"light": "[%key:component::light::title%]",
"lock": "[%key:component::lock::title%]",
"notify": "[%key:component::notify::title%]",
"number": "[%key:component::number::title%]",
"select": "[%key:component::select::title%]",
"sensor": "[%key:component::sensor::title%]",
"switch": "[%key:component::switch::title%]"
}

View File

@@ -34,7 +34,6 @@ async def async_setup_entry(
coordinator = NordPoolDataUpdateCoordinator(hass, config_entry)
await coordinator.fetch_data(dt_util.utcnow(), True)
await coordinator.update_listeners(dt_util.utcnow())
if not coordinator.last_update_success:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,

View File

@@ -44,10 +44,9 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
name=DOMAIN,
)
self.client = NordPoolClient(session=async_get_clientsession(hass))
self.data_unsub: Callable[[], None] | None = None
self.listener_unsub: Callable[[], None] | None = None
self.unsub: Callable[[], None] | None = None
def get_next_data_interval(self, now: datetime) -> datetime:
def get_next_interval(self, now: datetime) -> datetime:
"""Compute next time an update should occur."""
next_hour = dt_util.utcnow() + timedelta(hours=1)
next_run = datetime(
@@ -57,45 +56,23 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]):
next_hour.hour,
tzinfo=dt_util.UTC,
)
LOGGER.debug("Next data update at %s", next_run)
return next_run
def get_next_15_interval(self, now: datetime) -> datetime:
"""Compute next time we need to notify listeners."""
next_run = dt_util.utcnow() + timedelta(minutes=15)
next_minute = next_run.minute // 15 * 15
next_run = next_run.replace(
minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC
)
LOGGER.debug("Next listener update at %s", next_run)
LOGGER.debug("Next update at %s", next_run)
return next_run
async def async_shutdown(self) -> None:
"""Cancel any scheduled call, and ignore new runs."""
await super().async_shutdown()
if self.data_unsub:
self.data_unsub()
self.data_unsub = None
if self.listener_unsub:
self.listener_unsub()
self.listener_unsub = None
async def update_listeners(self, now: datetime) -> None:
"""Update entity listeners."""
self.listener_unsub = async_track_point_in_utc_time(
self.hass,
self.update_listeners,
self.get_next_15_interval(dt_util.utcnow()),
)
self.async_update_listeners()
if self.unsub:
self.unsub()
self.unsub = None
async def fetch_data(self, now: datetime, initial: bool = False) -> None:
"""Fetch data from Nord Pool."""
self.data_unsub = async_track_point_in_utc_time(
self.hass, self.fetch_data, self.get_next_data_interval(dt_util.utcnow())
self.unsub = async_track_point_in_utc_time(
self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow())
)
if self.config_entry.pref_disable_polling and not initial:
self.async_update_listeners()
return
try:
data = await self.handle_data(initial)

View File

@@ -8,6 +8,6 @@
"iot_class": "cloud_polling",
"loggers": ["pynordpool"],
"quality_scale": "platinum",
"requirements": ["pynordpool==0.3.1"],
"requirements": ["pynordpool==0.3.0"],
"single_config_entry": true
}

View File

@@ -307,7 +307,7 @@
},
"markdown": {
"name": "Format as Markdown",
"description": "Enable Markdown formatting for the message body. See the Markdown guide for syntax details: https://www.markdownguide.org/basic-syntax/."
"description": "Enable Markdown formatting for the message body (Web app only). See the Markdown guide for syntax details: https://www.markdownguide.org/basic-syntax/."
},
"tags": {
"name": "Tags/Emojis",

View File

@@ -35,8 +35,7 @@ from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .coordinator import OneDriveConfigEntry
_LOGGER = logging.getLogger(__name__)
MAX_CHUNK_SIZE = 60 * 1024 * 1024 # largest chunk possible, must be <= 60 MiB
TARGET_CHUNKS = 20
UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours
METADATA_VERSION = 2
CACHE_TTL = 300
@@ -162,22 +161,9 @@ class OneDriveBackupAgent(BackupAgent):
self._folder_id,
await open_stream(),
)
# determine chunk based on target chunks
upload_chunk_size = backup.size / TARGET_CHUNKS
# find the nearest multiple of 320KB
upload_chunk_size = round(upload_chunk_size / (320 * 1024)) * (320 * 1024)
# limit to max chunk size
upload_chunk_size = min(upload_chunk_size, MAX_CHUNK_SIZE)
# ensure minimum chunk size of 320KB
upload_chunk_size = max(upload_chunk_size, 320 * 1024)
try:
backup_file = await LargeFileUploadClient.upload(
self._token_function,
file,
upload_chunk_size=upload_chunk_size,
session=async_get_clientsession(self._hass),
self._token_function, file, session=async_get_clientsession(self._hass)
)
except HashMismatchError as err:
raise BackupAgentError(

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.15.6"]
"requirements": ["opower==0.15.5"]
}

View File

@@ -1,61 +0,0 @@
{
"entity": {
"sensor": {
"pressure": {
"default": "mdi:tooth-outline",
"state": {
"high": "mdi:tooth",
"low": "mdi:alert",
"power_button_pressed": "mdi:power",
"button_pressed": "mdi:radiobox-marked"
}
},
"sector": {
"default": "mdi:circle-outline",
"state": {
"sector_1": "mdi:circle-slice-2",
"sector_2": "mdi:circle-slice-4",
"sector_3": "mdi:circle-slice-6",
"sector_4": "mdi:circle-slice-8",
"success": "mdi:check-circle-outline"
}
},
"toothbrush_state": {
"default": "mdi:toothbrush-electric",
"state": {
"initializing": "mdi:sync",
"idle": "mdi:toothbrush-electric",
"running": "mdi:waveform",
"charging": "mdi:battery-charging",
"setup": "mdi:wrench",
"flight_menu": "mdi:airplane",
"selection_menu": "mdi:menu",
"off": "mdi:power",
"sleeping": "mdi:sleep",
"transport": "mdi:dolly"
}
},
"number_of_sectors": {
"default": "mdi:chart-pie"
},
"mode": {
"default": "mdi:toothbrush-paste",
"state": {
"daily_clean": "mdi:repeat-once",
"sensitive": "mdi:feather",
"gum_care": "mdi:tooth-outline",
"intense": "mdi:shape-circle-plus",
"whitening": "mdi:shimmer",
"whiten": "mdi:shimmer",
"tongue_cleaning": "mdi:gate-and",
"super_sensitive": "mdi:feather",
"massage": "mdi:spa",
"deep_clean": "mdi:water",
"turbo": "mdi:car-turbocharger",
"off": "mdi:power",
"settings": "mdi:cog-outline"
}
}
}
}
}

View File

@@ -3,13 +3,6 @@
from __future__ import annotations
from oralb_ble import OralBSensor, SensorUpdate
from oralb_ble.parser import (
IO_SERIES_MODES,
PRESSURE,
SECTOR_MAP,
SMART_SERIES_MODES,
STATES,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
@@ -46,8 +39,6 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
key=OralBSensor.SECTOR,
translation_key="sector",
entity_category=EntityCategory.DIAGNOSTIC,
options=[v.replace(" ", "_") for v in set(SECTOR_MAP.values()) | {"no_sector"}],
device_class=SensorDeviceClass.ENUM,
),
OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription(
key=OralBSensor.NUMBER_OF_SECTORS,
@@ -62,26 +53,16 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
),
OralBSensor.TOOTHBRUSH_STATE: SensorEntityDescription(
key=OralBSensor.TOOTHBRUSH_STATE,
translation_key="toothbrush_state",
options=[v.replace(" ", "_") for v in set(STATES.values())],
device_class=SensorDeviceClass.ENUM,
name=None,
),
OralBSensor.PRESSURE: SensorEntityDescription(
key=OralBSensor.PRESSURE,
translation_key="pressure",
options=[v.replace(" ", "_") for v in set(PRESSURE.values()) | {"low"}],
device_class=SensorDeviceClass.ENUM,
),
OralBSensor.MODE: SensorEntityDescription(
key=OralBSensor.MODE,
translation_key="mode",
entity_category=EntityCategory.DIAGNOSTIC,
options=[
v.replace(" ", "_")
for v in set(IO_SERIES_MODES.values()) | set(SMART_SERIES_MODES.values())
],
device_class=SensorDeviceClass.ENUM,
),
OralBSensor.SIGNAL_STRENGTH: SensorEntityDescription(
key=OralBSensor.SIGNAL_STRENGTH,
@@ -153,15 +134,7 @@ class OralBBluetoothSensorEntity(
@property
def native_value(self) -> str | int | None:
"""Return the native value."""
value = self.processor.entity_data.get(self.entity_key)
if isinstance(value, str):
value = value.replace(" ", "_")
if (
self.entity_description.options is not None
and value not in self.entity_description.options
): # append unknown values to enum
self.entity_description.options.append(value)
return value
return self.processor.entity_data.get(self.entity_key)
@property
def available(self) -> bool:

View File

@@ -22,15 +22,7 @@
"entity": {
"sensor": {
"sector": {
"name": "Sector",
"state": {
"no_sector": "No sector",
"sector_1": "Sector 1",
"sector_2": "Sector 2",
"sector_3": "Sector 3",
"sector_4": "Sector 4",
"success": "Success"
}
"name": "Sector"
},
"number_of_sectors": {
"name": "Number of sectors"
@@ -39,48 +31,10 @@
"name": "Sector timer"
},
"pressure": {
"name": "Pressure",
"state": {
"normal": "[%key:common::state::normal%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"power_button_pressed": "Power button pressed",
"button_pressed": "Button pressed"
}
"name": "Pressure"
},
"mode": {
"name": "Brushing mode",
"state": {
"daily_clean": "Daily clean",
"sensitive": "Sensitive",
"gum_care": "Gum care",
"intense": "Intense",
"whitening": "Whiten",
"whiten": "[%key:component::oralb::entity::sensor::mode::state::whitening%]",
"tongue_cleaning": "Tongue clean",
"super_sensitive": "Super sensitive",
"massage": "Massage",
"deep_clean": "Deep clean",
"turbo": "Turbo",
"off": "[%key:common::state::off%]",
"settings": "Settings"
}
},
"toothbrush_state": {
"state": {
"initializing": "Initializing",
"idle": "[%key:common::state::idle%]",
"running": "Running",
"charging": "[%key:common::state::charging%]",
"setup": "Setup",
"flight_menu": "Flight menu",
"selection_menu": "Selection menu",
"off": "[%key:common::state::off%]",
"sleeping": "Sleeping",
"transport": "Transport",
"final_test": "Final test",
"pcb_test": "PCB test"
}
"name": "Brushing mode"
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["ovoenergy"],
"requirements": ["ovoenergy==3.0.2"]
"requirements": ["ovoenergy==2.0.1"]
}

View File

@@ -1,36 +1,52 @@
"""Support for Plum Lightpad devices."""
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
import logging
from aiohttp import ContentTypeError
from requests.exceptions import ConnectTimeout, HTTPError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.exceptions import ConfigEntryNotReady
DOMAIN = "plum_lightpad"
from .const import DOMAIN
from .utils import load_plum
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Plum Lightpad from a config entry."""
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/plum_lightpad",
},
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
_LOGGER.debug("Setting up config entry with ID = %s", entry.unique_id)
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
try:
plum = await load_plum(username, password, hass)
except ContentTypeError as ex:
_LOGGER.error("Unable to authenticate to Plum cloud: %s", ex)
return False
except (ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect to Plum cloud: %s", ex)
raise ConfigEntryNotReady from ex
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = plum
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
def cleanup(event):
"""Clean up resources."""
plum.cleanup()
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup))
return True

View File

@@ -2,12 +2,59 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigFlow
import logging
from typing import Any
from . import DOMAIN
from aiohttp import ContentTypeError
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import DOMAIN
from .utils import load_plum
_LOGGER = logging.getLogger(__name__)
class PlumLightpadConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Plum Lightpad integration."""
VERSION = 1
def _show_form(self, errors=None):
schema = {
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(schema),
errors=errors or {},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user or redirected to by import."""
if not user_input:
return self._show_form()
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
# load Plum just so we know username/password work
try:
await load_plum(username, password, self.hass)
except (ContentTypeError, ConnectTimeout, HTTPError) as ex:
_LOGGER.error("Unable to connect/authenticate to Plum cloud: %s", str(ex))
return self._show_form({"base": "cannot_connect"})
await self.async_set_unique_id(username)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=username, data={CONF_USERNAME: username, CONF_PASSWORD: password}
)

View File

@@ -0,0 +1,3 @@
"""Constants for the Plum Lightpad component."""
DOMAIN = "plum_lightpad"

View File

@@ -0,0 +1,9 @@
{
"entity": {
"light": {
"glow_ring": {
"default": "mdi:crop-portrait"
}
}
}
}

View File

@@ -0,0 +1,201 @@
"""Support for Plum Lightpad lights."""
from __future__ import annotations
from typing import Any
from plumlightpad import Plum
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plum Lightpad dimmer lights and glow rings."""
plum: Plum = hass.data[DOMAIN][entry.entry_id]
def setup_entities(device) -> None:
entities: list[LightEntity] = []
if "lpid" in device:
lightpad = plum.get_lightpad(device["lpid"])
entities.append(GlowRing(lightpad=lightpad))
if "llid" in device:
logical_load = plum.get_load(device["llid"])
entities.append(PlumLight(load=logical_load))
async_add_entities(entities)
async def new_load(device):
setup_entities(device)
async def new_lightpad(device):
setup_entities(device)
device_web_session = async_get_clientsession(hass, verify_ssl=False)
entry.async_create_background_task(
hass,
plum.discover(
hass.loop,
loadListener=new_load,
lightpadListener=new_lightpad,
websession=device_web_session,
),
"plum.light-discover",
)
class PlumLight(LightEntity):
"""Representation of a Plum Lightpad dimmer."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(self, load):
"""Initialize the light."""
self._load = load
self._brightness = load.level
unique_id = f"{load.llid}.light"
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
manufacturer="Plum",
model="Dimmer",
name=load.name,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to dimmerchange events."""
self._load.add_event_listener("dimmerchange", self.dimmerchange)
def dimmerchange(self, event):
"""Change event handler updating the brightness."""
self._brightness = event["level"]
self.schedule_update_ha_state()
@property
def brightness(self) -> int:
"""Return the brightness of this switch between 0..255."""
return self._brightness
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self._brightness > 0
@property
def color_mode(self) -> ColorMode:
"""Flag supported features."""
if self._load.dimmable:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Flag supported color modes."""
return {self.color_mode}
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
if ATTR_BRIGHTNESS in kwargs:
await self._load.turn_on(kwargs[ATTR_BRIGHTNESS])
else:
await self._load.turn_on()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._load.turn_off()
class GlowRing(LightEntity):
"""Representation of a Plum Lightpad dimmer glow ring."""
_attr_color_mode = ColorMode.HS
_attr_should_poll = False
_attr_translation_key = "glow_ring"
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, lightpad):
"""Initialize the light."""
self._lightpad = lightpad
self._attr_name = f"{lightpad.friendly_name} Glow Ring"
self._attr_is_on = lightpad.glow_enabled
self._glow_intensity = lightpad.glow_intensity
unique_id = f"{self._lightpad.lpid}.glow"
self._attr_unique_id = unique_id
self._red = lightpad.glow_color["red"]
self._green = lightpad.glow_color["green"]
self._blue = lightpad.glow_color["blue"]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
manufacturer="Plum",
model="Glow Ring",
name=self._attr_name,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to configchange events."""
self._lightpad.add_event_listener("configchange", self.configchange_event)
def configchange_event(self, event):
"""Handle Configuration change event."""
config = event["changes"]
self._attr_is_on = config["glowEnabled"]
self._glow_intensity = config["glowIntensity"]
self._red = config["glowColor"]["red"]
self._green = config["glowColor"]["green"]
self._blue = config["glowColor"]["blue"]
self.schedule_update_ha_state()
@property
def hs_color(self):
"""Return the hue and saturation color value [float, float]."""
return color_util.color_RGB_to_hs(self._red, self._green, self._blue)
@property
def brightness(self) -> int:
"""Return the brightness of this switch between 0..255."""
return min(max(int(round(self._glow_intensity * 255, 0)), 0), 255)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
if ATTR_BRIGHTNESS in kwargs:
brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0
await self._lightpad.set_config({"glowIntensity": brightness_pct})
elif ATTR_HS_COLOR in kwargs:
hs_color = kwargs[ATTR_HS_COLOR]
red, green, blue = color_util.color_hs_to_RGB(*hs_color)
await self._lightpad.set_glow_color(red, green, blue, 0)
else:
await self._lightpad.set_config({"glowEnabled": True})
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
if ATTR_BRIGHTNESS in kwargs:
brightness_pct = kwargs[ATTR_BRIGHTNESS] / 255.0
await self._lightpad.set_config({"glowIntensity": brightness_pct})
else:
await self._lightpad.set_config({"glowEnabled": False})

View File

@@ -1,9 +1,10 @@
{
"domain": "plum_lightpad",
"name": "Plum Lightpad",
"codeowners": [],
"codeowners": ["@ColinHarrington", "@prystupa"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/plum_lightpad",
"integration_type": "system",
"iot_class": "local_push",
"requirements": []
"loggers": ["plumlightpad"],
"requirements": ["plumlightpad==0.0.11"]
}

View File

@@ -1,8 +1,18 @@
{
"issues": {
"integration_removed": {
"title": "The Plum Lightpad integration has been removed",
"description": "The Plum Lightpad integration has been removed from Home Assistant.\n\nThe required cloud services are no longer available since the Plum servers have been shut down. To resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Plum Lightpad integration entries]({entries})."
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
}
}

View File

@@ -0,0 +1,14 @@
"""Reusable utilities for the Plum Lightpad component."""
from plumlightpad import Plum
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
async def load_plum(username: str, password: str, hass: HomeAssistant) -> Plum:
"""Initialize Plum Lightpad API and load metadata stored in the cloud."""
plum = Plum(username, password)
cloud_web_session = async_get_clientsession(hass, verify_ssl=True)
await plum.loadCloudData(cloud_web_session)
return plum

View File

@@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .coordinator import PortainerCoordinator
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH]
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]

View File

@@ -131,7 +131,15 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
# Container ID's are ephemeral, so use the container name for the unique ID
# The first one, should always be unique, it's fine if users have aliases
# According to Docker's API docs, the first name is unique
device_identifier = (
self._device_info.names[0].replace("/", " ").strip()
if self._device_info.names
else None
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
@property
def available(self) -> bool:

View File

@@ -1,128 +0,0 @@
"""Support for Portainer buttons."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any
from pyportainer import Portainer
from pyportainer.exceptions import (
PortainerAuthenticationError,
PortainerConnectionError,
PortainerTimeoutError,
)
from pyportainer.models.docker import DockerContainer
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .const import DOMAIN
from .coordinator import PortainerCoordinator, PortainerCoordinatorData
from .entity import PortainerContainerEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class PortainerButtonDescription(ButtonEntityDescription):
"""Class to describe a Portainer button entity."""
press_action: Callable[
[Portainer, int, str],
Coroutine[Any, Any, None],
]
BUTTONS: tuple[PortainerButtonDescription, ...] = (
PortainerButtonDescription(
key="restart",
name="Restart Container",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=(
lambda portainer, endpoint_id, container_id: portainer.restart_container(
endpoint_id, container_id
)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PortainerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Portainer buttons."""
coordinator: PortainerCoordinator = entry.runtime_data
async_add_entities(
PortainerButton(
coordinator=coordinator,
entity_description=entity_description,
device_info=container,
via_device=endpoint,
)
for endpoint in coordinator.data.values()
for container in endpoint.containers.values()
for entity_description in BUTTONS
)
class PortainerButton(PortainerContainerEntity, ButtonEntity):
"""Defines a Portainer button."""
entity_description: PortainerButtonDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerButtonDescription,
device_info: DockerContainer,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer button entity."""
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)
device_identifier = (
self._device_info.names[0].replace("/", " ").strip()
if self._device_info.names
else None
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
async def async_press(self) -> None:
"""Trigger the Portainer button press service."""
try:
await self.entity_description.press_action(
self.coordinator.portainer, self.endpoint_id, self.device_id
)
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err

View File

@@ -1,9 +1,7 @@
"""Base class for Portainer entities."""
from pyportainer.models.docker import DockerContainer
from yarl import URL
from homeassistant.const import CONF_URL
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -33,9 +31,6 @@ class PortainerEndpointEntity(PortainerCoordinatorEntity):
identifiers={
(DOMAIN, f"{coordinator.config_entry.entry_id}_{self.device_id}")
},
configuration_url=URL(
f"{coordinator.config_entry.data[CONF_URL]}#!/{self.device_id}/docker/dashboard"
),
manufacturer=DEFAULT_NAME,
model="Endpoint",
name=device_info.endpoint.name,
@@ -57,25 +52,22 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
self.device_id = self._device_info.id
self.endpoint_id = via_device.endpoint.id
# Container ID's are ephemeral, so use the container name for the unique ID
# The first one, should always be unique, it's fine if users have aliases
# According to Docker's API docs, the first name is unique
assert self._device_info.names, "Container names list unexpectedly empty"
self.device_name = self._device_info.names[0].replace("/", " ").strip()
device_name = (
self._device_info.names[0].replace("/", " ").strip()
if self._device_info.names
else None
)
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_name}")
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}")
},
manufacturer=DEFAULT_NAME,
configuration_url=URL(
f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/containers/{self.device_id}"
),
model="Container",
name=self.device_name,
name=device_name,
via_device=(
DOMAIN,
f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}",
),
translation_key=None if self.device_name else "unknown_container",
translation_key=None if device_name else "unknown_container",
)

View File

@@ -1,12 +0,0 @@
{
"entity": {
"switch": {
"container": {
"default": "mdi:arrow-down-box",
"state": {
"on": "mdi:arrow-up-box"
}
}
}
}
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/portainer",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.3"]
"requirements": ["pyportainer==1.0.2"]
}

View File

@@ -26,7 +26,10 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done

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