mirror of
https://github.com/home-assistant/core.git
synced 2025-10-05 09:49:26 +00:00
Compare commits
2 Commits
dev
...
mqtt-suben
Author | SHA1 | Date | |
---|---|---|---|
![]() |
98e0513866 | ||
![]() |
972e643d88 |
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -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"
|
||||
|
@@ -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
4
CODEOWNERS
generated
@@ -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
|
||||
|
10
build.yaml
10
build.yaml
@@ -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
|
||||
|
5
homeassistant/brands/ibm.json
Normal file
5
homeassistant/brands/ibm.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "ibm",
|
||||
"name": "IBM",
|
||||
"integrations": ["watson_iot", "watson_tts"]
|
||||
}
|
@@ -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
|
||||
)
|
||||
|
@@ -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": {
|
||||
|
@@ -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(
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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": {
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.2.8"]
|
||||
"requirements": ["aioamazondevices==6.2.7"]
|
||||
}
|
||||
|
@@ -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": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
]
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.1"]
|
||||
"requirements": ["pycync==0.4.0"]
|
||||
}
|
||||
|
@@ -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."]
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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]
|
||||
|
@@ -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(
|
||||
|
@@ -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)
|
||||
|
@@ -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[
|
||||
|
@@ -17,6 +17,6 @@
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.1",
|
||||
"aiodiscover==2.7.1",
|
||||
"cached-ipaddress==1.0.1"
|
||||
"cached-ipaddress==0.10.0"
|
||||
]
|
||||
}
|
||||
|
@@ -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."
|
||||
|
@@ -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."]
|
||||
}
|
||||
|
@@ -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."""
|
||||
|
@@ -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)},
|
||||
|
@@ -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."""
|
||||
|
@@ -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": {
|
||||
|
@@ -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(
|
||||
|
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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"
|
||||
|
@@ -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(
|
||||
|
@@ -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)
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -174,9 +174,6 @@
|
||||
},
|
||||
"collected_items": {
|
||||
"default": "mdi:sack"
|
||||
},
|
||||
"last_checkin": {
|
||||
"default": "mdi:login-variant"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -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(
|
||||
|
@@ -290,9 +290,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"last_checkin": {
|
||||
"name": "Last check-in"
|
||||
},
|
||||
"health": {
|
||||
"name": "Health",
|
||||
"unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]"
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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"
|
||||
]
|
||||
}
|
||||
|
@@ -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"
|
||||
],
|
||||
|
@@ -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."]
|
||||
}
|
||||
|
@@ -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()
|
||||
|
@@ -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:
|
||||
|
@@ -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])
|
||||
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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]
|
||||
|
@@ -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)
|
@@ -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)
|
@@ -1,5 +0,0 @@
|
||||
"""Constants for the Lunatone integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "lunatone"
|
@@ -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}
|
@@ -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()
|
@@ -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"]
|
||||
}
|
@@ -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
|
@@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -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(
|
||||
|
@@ -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),
|
||||
|
@@ -351,7 +351,6 @@ DISCOVERY_SCHEMAS = [
|
||||
required_attributes=(
|
||||
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
|
||||
),
|
||||
allow_multi=True, # also used for climate entity
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
|
@@ -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,
|
||||
|
@@ -9,8 +9,5 @@
|
||||
"unknown_media_source": {
|
||||
"message": "Unknown media source: {domain}"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"sources_default": "Media sources"
|
||||
}
|
||||
}
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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"
|
||||
|
@@ -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]
|
||||
|
@@ -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(
|
||||
{
|
||||
|
@@ -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%]"
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -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(
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.15.6"]
|
||||
"requirements": ["opower==0.15.5"]
|
||||
}
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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:
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ovoenergy"],
|
||||
"requirements": ["ovoenergy==3.0.2"]
|
||||
"requirements": ["ovoenergy==2.0.1"]
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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}
|
||||
)
|
||||
|
3
homeassistant/components/plum_lightpad/const.py
Normal file
3
homeassistant/components/plum_lightpad/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Plum Lightpad component."""
|
||||
|
||||
DOMAIN = "plum_lightpad"
|
9
homeassistant/components/plum_lightpad/icons.json
Normal file
9
homeassistant/components/plum_lightpad/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"light": {
|
||||
"glow_ring": {
|
||||
"default": "mdi:crop-portrait"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
201
homeassistant/components/plum_lightpad/light.py
Normal file
201
homeassistant/components/plum_lightpad/light.py
Normal 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})
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
14
homeassistant/components/plum_lightpad/utils.py
Normal file
14
homeassistant/components/plum_lightpad/utils.py
Normal 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
|
@@ -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]
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
@@ -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",
|
||||
)
|
||||
|
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"container": {
|
||||
"default": "mdi:arrow-down-box",
|
||||
"state": {
|
||||
"on": "mdi:arrow-up-box"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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
Reference in New Issue
Block a user