mirror of
				https://github.com/home-assistant/core.git
				synced 2025-11-04 00:19:31 +00:00 
			
		
		
		
	Compare commits
	
		
			37 Commits
		
	
	
		
			power
			...
			epenet-pat
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					7b2e4dbe16 | ||
| 
						 | 
					1f812e9f89 | ||
| 
						 | 
					b182d5ce87 | ||
| 
						 | 
					175365bdea | ||
| 
						 | 
					cbe52cbfca | ||
| 
						 | 
					9251dde2c6 | ||
| 
						 | 
					24d77cc453 | ||
| 
						 | 
					a1f98abe49 | ||
| 
						 | 
					d25dde1d11 | ||
| 
						 | 
					8ec483b38b | ||
| 
						 | 
					bf14caca69 | ||
| 
						 | 
					e5fb6b2fb2 | ||
| 
						 | 
					7dfeb3a3f6 | ||
| 
						 | 
					9d3b1562c4 | ||
| 
						 | 
					e14407f066 | ||
| 
						 | 
					67872e3746 | ||
| 
						 | 
					06bd1a2003 | ||
| 
						 | 
					37ea360304 | ||
| 
						 | 
					25ce57424c | ||
| 
						 | 
					3d46ab549d | ||
| 
						 | 
					567cc9f842 | ||
| 
						 | 
					b5457a5abd | ||
| 
						 | 
					e4b5e35d1d | ||
| 
						 | 
					12023c33b5 | ||
| 
						 | 
					a28749937c | ||
| 
						 | 
					3fe37d651f | ||
| 
						 | 
					cb3424cdf0 | ||
| 
						 | 
					a799f7ff91 | ||
| 
						 | 
					34ab725b75 | ||
| 
						 | 
					2dfc7f02ba | ||
| 
						 | 
					c8919222bd | ||
| 
						 | 
					a888264d2f | ||
| 
						 | 
					ae84c7e15d | ||
| 
						 | 
					415c8b490b | ||
| 
						 | 
					6038f15406 | ||
| 
						 | 
					a8758253c4 | ||
| 
						 | 
					fa4eb2e820 | 
							
								
								
									
										2
									
								
								.github/workflows/builder.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/builder.yml
									
									
									
									
										vendored
									
									
								
							@@ -326,7 +326,7 @@ jobs:
 | 
			
		||||
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
 | 
			
		||||
 | 
			
		||||
      - name: Install Cosign
 | 
			
		||||
        uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
 | 
			
		||||
        uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
 | 
			
		||||
        with:
 | 
			
		||||
          cosign-release: "v2.2.3"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								CODEOWNERS
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								CODEOWNERS
									
									
									
										generated
									
									
									
								
							@@ -619,6 +619,8 @@ build.json @home-assistant/supervisor
 | 
			
		||||
/tests/components/greeneye_monitor/ @jkeljo
 | 
			
		||||
/homeassistant/components/group/ @home-assistant/core
 | 
			
		||||
/tests/components/group/ @home-assistant/core
 | 
			
		||||
/homeassistant/components/growatt_server/ @johanzander
 | 
			
		||||
/tests/components/growatt_server/ @johanzander
 | 
			
		||||
/homeassistant/components/guardian/ @bachya
 | 
			
		||||
/tests/components/guardian/ @bachya
 | 
			
		||||
/homeassistant/components/habitica/ @tr4nt0r
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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.10.1
 | 
			
		||||
  armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
 | 
			
		||||
  armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
 | 
			
		||||
  amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
 | 
			
		||||
  i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
 | 
			
		||||
codenotary:
 | 
			
		||||
  signer: notary@home-assistant.io
 | 
			
		||||
  base_image: notary@home-assistant.io
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,7 @@
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)",
 | 
			
		||||
          "clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behaviour is to clip such values to 0"
 | 
			
		||||
          "clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behavior is to clip such values to 0"
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -146,7 +146,7 @@
 | 
			
		||||
        },
 | 
			
		||||
        "state": {
 | 
			
		||||
          "title": "Add a Bayesian sensor",
 | 
			
		||||
          "description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behaviour can be overridden by adding observations for the same entity's other states.",
 | 
			
		||||
          "description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behavior can be overridden by adding observations for the same entity's other states.",
 | 
			
		||||
 | 
			
		||||
          "data": {
 | 
			
		||||
            "name": "[%key:common::config_flow::data::name%]",
 | 
			
		||||
 
 | 
			
		||||
@@ -113,7 +113,6 @@ __all__ = [
 | 
			
		||||
    "BluetoothServiceInfo",
 | 
			
		||||
    "BluetoothServiceInfoBleak",
 | 
			
		||||
    "HaBluetoothConnector",
 | 
			
		||||
    "HomeAssistantRemoteScanner",
 | 
			
		||||
    "async_address_present",
 | 
			
		||||
    "async_ble_device_from_address",
 | 
			
		||||
    "async_clear_address_from_match_history",
 | 
			
		||||
 
 | 
			
		||||
@@ -74,7 +74,10 @@ from .const import (
 | 
			
		||||
    StreamType,
 | 
			
		||||
)
 | 
			
		||||
from .helper import get_camera_from_entity_id
 | 
			
		||||
from .img_util import scale_jpeg_camera_image
 | 
			
		||||
from .img_util import (
 | 
			
		||||
    TurboJPEGSingleton,  # noqa: F401
 | 
			
		||||
    scale_jpeg_camera_image,
 | 
			
		||||
)
 | 
			
		||||
from .prefs import (
 | 
			
		||||
    CameraPreferences,
 | 
			
		||||
    DynamicStreamSettings,  # noqa: F401
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ from homeassistant.components.alexa import (
 | 
			
		||||
    errors as alexa_errors,
 | 
			
		||||
    smart_home as alexa_smart_home,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.camera.webrtc import async_register_ice_servers
 | 
			
		||||
from homeassistant.components.camera import async_register_ice_servers
 | 
			
		||||
from homeassistant.components.google_assistant import smart_home as ga
 | 
			
		||||
from homeassistant.const import __version__ as HA_VERSION
 | 
			
		||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,9 @@ from hass_nabucasa.google_report_state import ErrorResponse
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
 | 
			
		||||
from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
 | 
			
		||||
from homeassistant.components.google_assistant.helpers import AbstractConfig
 | 
			
		||||
from homeassistant.components.google_assistant.helpers import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    AbstractConfig,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.homeassistant.exposed_entities import (
 | 
			
		||||
    async_expose_entity,
 | 
			
		||||
    async_get_assistant_settings,
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ from hass_nabucasa.voice import MAP_VOICE, Gender
 | 
			
		||||
from homeassistant.auth.const import GROUP_ID_ADMIN
 | 
			
		||||
from homeassistant.auth.models import User
 | 
			
		||||
from homeassistant.components import webhook
 | 
			
		||||
from homeassistant.components.google_assistant.http import (
 | 
			
		||||
from homeassistant.components.google_assistant.http import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    async_get_users as async_get_google_assistant_users,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,9 @@ from typing import Any
 | 
			
		||||
import uuid
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
 | 
			
		||||
from homeassistant.components.automation.config import async_validate_config_item
 | 
			
		||||
from homeassistant.components.automation.config import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    async_validate_config_item,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.config import AUTOMATION_CONFIG_PATH
 | 
			
		||||
from homeassistant.const import CONF_ID, SERVICE_RELOAD
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,9 @@ from __future__ import annotations
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
 | 
			
		||||
from homeassistant.components.script.config import async_validate_config_item
 | 
			
		||||
from homeassistant.components.script.config import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    async_validate_config_item,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.config import SCRIPT_CONFIG_PATH
 | 
			
		||||
from homeassistant.const import SERVICE_RELOAD
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
 
 | 
			
		||||
@@ -569,10 +569,13 @@ class ChatLog:
 | 
			
		||||
        if llm_api:
 | 
			
		||||
            prompt_parts.append(llm_api.api_prompt)
 | 
			
		||||
 | 
			
		||||
        # Append current date and time to the prompt if the corresponding tool is not provided
 | 
			
		||||
        llm_tools: list[llm.Tool] = llm_api.tools if llm_api else []
 | 
			
		||||
        if not any(tool.name.endswith("GetDateTime") for tool in llm_tools):
 | 
			
		||||
            prompt_parts.append(
 | 
			
		||||
                await self._async_expand_prompt_template(
 | 
			
		||||
                    llm_context,
 | 
			
		||||
                llm.BASE_PROMPT,
 | 
			
		||||
                    llm.DATE_TIME_PROMPT,
 | 
			
		||||
                    llm_context.language,
 | 
			
		||||
                    user_name,
 | 
			
		||||
                )
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,9 @@ from __future__ import annotations
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
 | 
			
		||||
from homeassistant.components.manual.alarm_control_panel import ManualAlarm
 | 
			
		||||
from homeassistant.components.manual.alarm_control_panel import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    ManualAlarm,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.const import CONF_ARMING_TIME, CONF_DELAY_TIME, CONF_TRIGGER_TIME
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
 
 | 
			
		||||
@@ -139,6 +139,7 @@ class DemoCover(CoverEntity):
 | 
			
		||||
            self.async_write_ha_state()
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self._is_opening = False
 | 
			
		||||
        self._is_closing = True
 | 
			
		||||
        self._listen_cover()
 | 
			
		||||
        self._requested_closing = True
 | 
			
		||||
@@ -162,6 +163,7 @@ class DemoCover(CoverEntity):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self._is_opening = True
 | 
			
		||||
        self._is_closing = False
 | 
			
		||||
        self._listen_cover()
 | 
			
		||||
        self._requested_closing = False
 | 
			
		||||
        self.async_write_ha_state()
 | 
			
		||||
@@ -181,10 +183,14 @@ class DemoCover(CoverEntity):
 | 
			
		||||
        if self._position == position:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self._is_closing = position < (self._position or 0)
 | 
			
		||||
        self._is_opening = not self._is_closing
 | 
			
		||||
 | 
			
		||||
        self._listen_cover()
 | 
			
		||||
        self._requested_closing = (
 | 
			
		||||
            self._position is not None and position < self._position
 | 
			
		||||
        )
 | 
			
		||||
        self.async_write_ha_state()
 | 
			
		||||
 | 
			
		||||
    async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
 | 
			
		||||
        """Move the cover til to a specific position."""
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,5 @@
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "loggers": ["pydoods"],
 | 
			
		||||
  "quality_scale": "legacy",
 | 
			
		||||
  "requirements": ["pydoods==1.0.2", "Pillow==11.3.0"]
 | 
			
		||||
  "requirements": ["pydoods==1.0.2", "Pillow==12.0.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ from __future__ import annotations
 | 
			
		||||
import asyncio
 | 
			
		||||
from collections import Counter
 | 
			
		||||
from collections.abc import Awaitable, Callable
 | 
			
		||||
from typing import Literal, NotRequired, TypedDict
 | 
			
		||||
from typing import Literal, TypedDict
 | 
			
		||||
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
 | 
			
		||||
class FlowFromGridSourceType(TypedDict):
 | 
			
		||||
    """Dictionary describing the 'from' stat for the grid source."""
 | 
			
		||||
 | 
			
		||||
    # statistic_id of an energy meter (kWh)
 | 
			
		||||
    # statistic_id of a an energy meter (kWh)
 | 
			
		||||
    stat_energy_from: str
 | 
			
		||||
 | 
			
		||||
    # statistic_id of costs ($) incurred from the energy meter
 | 
			
		||||
@@ -58,14 +58,6 @@ class FlowToGridSourceType(TypedDict):
 | 
			
		||||
    number_energy_price: float | None  # Price for energy ($/kWh)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GridPowerSourceType(TypedDict):
 | 
			
		||||
    """Dictionary holding the source of grid power consumption."""
 | 
			
		||||
 | 
			
		||||
    # statistic_id of a power meter (kW)
 | 
			
		||||
    # negative values indicate grid return
 | 
			
		||||
    stat_power: str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GridSourceType(TypedDict):
 | 
			
		||||
    """Dictionary holding the source of grid energy consumption."""
 | 
			
		||||
 | 
			
		||||
@@ -73,7 +65,6 @@ class GridSourceType(TypedDict):
 | 
			
		||||
 | 
			
		||||
    flow_from: list[FlowFromGridSourceType]
 | 
			
		||||
    flow_to: list[FlowToGridSourceType]
 | 
			
		||||
    power: NotRequired[list[GridPowerSourceType]]
 | 
			
		||||
 | 
			
		||||
    cost_adjustment_day: float
 | 
			
		||||
 | 
			
		||||
@@ -84,7 +75,6 @@ class SolarSourceType(TypedDict):
 | 
			
		||||
    type: Literal["solar"]
 | 
			
		||||
 | 
			
		||||
    stat_energy_from: str
 | 
			
		||||
    stat_power: NotRequired[str]
 | 
			
		||||
    config_entry_solar_forecast: list[str] | None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -95,8 +85,6 @@ class BatterySourceType(TypedDict):
 | 
			
		||||
 | 
			
		||||
    stat_energy_from: str
 | 
			
		||||
    stat_energy_to: str
 | 
			
		||||
    # positive when discharging, negative when charging
 | 
			
		||||
    stat_power: NotRequired[str]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GasSourceType(TypedDict):
 | 
			
		||||
@@ -148,15 +136,12 @@ class DeviceConsumption(TypedDict):
 | 
			
		||||
    # This is an ever increasing value
 | 
			
		||||
    stat_consumption: str
 | 
			
		||||
 | 
			
		||||
    # optional power meter
 | 
			
		||||
    stat_power: NotRequired[str]
 | 
			
		||||
 | 
			
		||||
    # An optional custom name for display in energy graphs
 | 
			
		||||
    name: str | None
 | 
			
		||||
 | 
			
		||||
    # An optional statistic_id identifying a device
 | 
			
		||||
    # that includes this device's consumption in its total
 | 
			
		||||
    included_in_stat: NotRequired[str]
 | 
			
		||||
    included_in_stat: str | None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EnergyPreferences(TypedDict):
 | 
			
		||||
@@ -209,12 +194,6 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
 | 
			
		||||
    {
 | 
			
		||||
        vol.Required("stat_power"): str,
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
 | 
			
		||||
    """Generate a validator that ensures a value is only used once."""
 | 
			
		||||
@@ -245,10 +224,6 @@ GRID_SOURCE_SCHEMA = vol.Schema(
 | 
			
		||||
            [FLOW_TO_GRID_SOURCE_SCHEMA],
 | 
			
		||||
            _generate_unique_value_validator("stat_energy_to"),
 | 
			
		||||
        ),
 | 
			
		||||
        vol.Optional("power"): vol.All(
 | 
			
		||||
            [GRID_POWER_SOURCE_SCHEMA],
 | 
			
		||||
            _generate_unique_value_validator("stat_power"),
 | 
			
		||||
        ),
 | 
			
		||||
        vol.Required("cost_adjustment_day"): vol.Coerce(float),
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
@@ -256,7 +231,6 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
 | 
			
		||||
    {
 | 
			
		||||
        vol.Required("type"): "solar",
 | 
			
		||||
        vol.Required("stat_energy_from"): str,
 | 
			
		||||
        vol.Optional("stat_power"): str,
 | 
			
		||||
        vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
@@ -265,7 +239,6 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
 | 
			
		||||
        vol.Required("type"): "battery",
 | 
			
		||||
        vol.Required("stat_energy_from"): str,
 | 
			
		||||
        vol.Required("stat_energy_to"): str,
 | 
			
		||||
        vol.Optional("stat_power"): str,
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
GAS_SOURCE_SCHEMA = vol.Schema(
 | 
			
		||||
@@ -321,7 +294,6 @@ ENERGY_SOURCE_SCHEMA = vol.All(
 | 
			
		||||
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
 | 
			
		||||
    {
 | 
			
		||||
        vol.Required("stat_consumption"): str,
 | 
			
		||||
        vol.Optional("stat_power"): str,
 | 
			
		||||
        vol.Optional("name"): str,
 | 
			
		||||
        vol.Optional("included_in_stat"): str,
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,6 @@ from homeassistant.const import (
 | 
			
		||||
    STATE_UNAVAILABLE,
 | 
			
		||||
    STATE_UNKNOWN,
 | 
			
		||||
    UnitOfEnergy,
 | 
			
		||||
    UnitOfPower,
 | 
			
		||||
    UnitOfVolume,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
 | 
			
		||||
@@ -24,17 +23,12 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
 | 
			
		||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
 | 
			
		||||
    sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
 | 
			
		||||
}
 | 
			
		||||
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
 | 
			
		||||
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
 | 
			
		||||
    sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ENERGY_PRICE_UNITS = tuple(
 | 
			
		||||
    f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
 | 
			
		||||
)
 | 
			
		||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
 | 
			
		||||
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
 | 
			
		||||
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
 | 
			
		||||
GAS_USAGE_DEVICE_CLASSES = (
 | 
			
		||||
    sensor.SensorDeviceClass.ENERGY,
 | 
			
		||||
    sensor.SensorDeviceClass.GAS,
 | 
			
		||||
@@ -88,10 +82,6 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
 | 
			
		||||
                f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
 | 
			
		||||
            ),
 | 
			
		||||
        }
 | 
			
		||||
    if issue_type == POWER_UNIT_ERROR:
 | 
			
		||||
        return {
 | 
			
		||||
            "power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
 | 
			
		||||
        }
 | 
			
		||||
    if issue_type == GAS_UNIT_ERROR:
 | 
			
		||||
        return {
 | 
			
		||||
            "energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
 | 
			
		||||
@@ -169,7 +159,7 @@ class EnergyPreferencesValidation:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@callback
 | 
			
		||||
def _async_validate_stat_common(
 | 
			
		||||
def _async_validate_usage_stat(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
 | 
			
		||||
    stat_id: str,
 | 
			
		||||
@@ -177,41 +167,37 @@ def _async_validate_stat_common(
 | 
			
		||||
    allowed_units: Mapping[str, Sequence[str]],
 | 
			
		||||
    unit_error: str,
 | 
			
		||||
    issues: ValidationIssues,
 | 
			
		||||
    check_negative: bool = False,
 | 
			
		||||
) -> str | None:
 | 
			
		||||
    """Validate common aspects of a statistic.
 | 
			
		||||
 | 
			
		||||
    Returns the entity_id if validation succeeds, None otherwise.
 | 
			
		||||
    """
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Validate a statistic."""
 | 
			
		||||
    if stat_id not in metadata:
 | 
			
		||||
        issues.add_issue(hass, "statistics_not_defined", stat_id)
 | 
			
		||||
 | 
			
		||||
    has_entity_source = valid_entity_id(stat_id)
 | 
			
		||||
 | 
			
		||||
    if not has_entity_source:
 | 
			
		||||
        return None
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    entity_id = stat_id
 | 
			
		||||
 | 
			
		||||
    if not recorder.is_entity_recorded(hass, entity_id):
 | 
			
		||||
        issues.add_issue(hass, "recorder_untracked", entity_id)
 | 
			
		||||
        return None
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if (state := hass.states.get(entity_id)) is None:
 | 
			
		||||
        issues.add_issue(hass, "entity_not_defined", entity_id)
 | 
			
		||||
        return None
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
 | 
			
		||||
        issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
 | 
			
		||||
        return None
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        current_value: float | None = float(state.state)
 | 
			
		||||
    except ValueError:
 | 
			
		||||
        issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
 | 
			
		||||
        return None
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    if check_negative and current_value is not None and current_value < 0:
 | 
			
		||||
    if current_value is not None and current_value < 0:
 | 
			
		||||
        issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
 | 
			
		||||
 | 
			
		||||
    device_class = state.attributes.get(ATTR_DEVICE_CLASS)
 | 
			
		||||
@@ -225,36 +211,6 @@ def _async_validate_stat_common(
 | 
			
		||||
        if device_class and unit not in allowed_units.get(device_class, []):
 | 
			
		||||
            issues.add_issue(hass, unit_error, entity_id, unit)
 | 
			
		||||
 | 
			
		||||
    return entity_id
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@callback
 | 
			
		||||
def _async_validate_usage_stat(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
 | 
			
		||||
    stat_id: str,
 | 
			
		||||
    allowed_device_classes: Sequence[str],
 | 
			
		||||
    allowed_units: Mapping[str, Sequence[str]],
 | 
			
		||||
    unit_error: str,
 | 
			
		||||
    issues: ValidationIssues,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Validate a statistic."""
 | 
			
		||||
    entity_id = _async_validate_stat_common(
 | 
			
		||||
        hass,
 | 
			
		||||
        metadata,
 | 
			
		||||
        stat_id,
 | 
			
		||||
        allowed_device_classes,
 | 
			
		||||
        allowed_units,
 | 
			
		||||
        unit_error,
 | 
			
		||||
        issues,
 | 
			
		||||
        check_negative=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if entity_id is None:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    state = hass.states.get(entity_id)
 | 
			
		||||
    assert state is not None
 | 
			
		||||
    state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
 | 
			
		||||
 | 
			
		||||
    allowed_state_classes = [
 | 
			
		||||
@@ -299,39 +255,6 @@ def _async_validate_price_entity(
 | 
			
		||||
        issues.add_issue(hass, unit_error, entity_id, unit)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@callback
 | 
			
		||||
def _async_validate_power_stat(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
 | 
			
		||||
    stat_id: str,
 | 
			
		||||
    allowed_device_classes: Sequence[str],
 | 
			
		||||
    allowed_units: Mapping[str, Sequence[str]],
 | 
			
		||||
    unit_error: str,
 | 
			
		||||
    issues: ValidationIssues,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Validate a power statistic."""
 | 
			
		||||
    entity_id = _async_validate_stat_common(
 | 
			
		||||
        hass,
 | 
			
		||||
        metadata,
 | 
			
		||||
        stat_id,
 | 
			
		||||
        allowed_device_classes,
 | 
			
		||||
        allowed_units,
 | 
			
		||||
        unit_error,
 | 
			
		||||
        issues,
 | 
			
		||||
        check_negative=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if entity_id is None:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    state = hass.states.get(entity_id)
 | 
			
		||||
    assert state is not None
 | 
			
		||||
    state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
 | 
			
		||||
 | 
			
		||||
    if state_class != sensor.SensorStateClass.MEASUREMENT:
 | 
			
		||||
        issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@callback
 | 
			
		||||
def _async_validate_cost_stat(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
@@ -511,21 +434,6 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
 | 
			
		||||
                        )
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
            for power_stat in source.get("power", []):
 | 
			
		||||
                wanted_statistics_metadata.add(power_stat["stat_power"])
 | 
			
		||||
                validate_calls.append(
 | 
			
		||||
                    functools.partial(
 | 
			
		||||
                        _async_validate_power_stat,
 | 
			
		||||
                        hass,
 | 
			
		||||
                        statistics_metadata,
 | 
			
		||||
                        power_stat["stat_power"],
 | 
			
		||||
                        POWER_USAGE_DEVICE_CLASSES,
 | 
			
		||||
                        POWER_USAGE_UNITS,
 | 
			
		||||
                        POWER_UNIT_ERROR,
 | 
			
		||||
                        source_result,
 | 
			
		||||
                    )
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        elif source["type"] == "gas":
 | 
			
		||||
            wanted_statistics_metadata.add(source["stat_energy_from"])
 | 
			
		||||
            validate_calls.append(
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,9 @@ from homeassistant.components.ffmpeg import (
 | 
			
		||||
    FFmpegManager,
 | 
			
		||||
    get_ffmpeg_manager,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor
 | 
			
		||||
from homeassistant.components.ffmpeg_motion.binary_sensor import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    FFmpegBinarySensor,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.const import CONF_NAME
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers import config_validation as cv
 | 
			
		||||
 
 | 
			
		||||
@@ -6,9 +6,8 @@ import logging
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.camera import CameraEntityFeature
 | 
			
		||||
from homeassistant.components.ffmpeg.camera import (
 | 
			
		||||
    CONF_EXTRA_ARGUMENTS,
 | 
			
		||||
    CONF_INPUT,
 | 
			
		||||
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, CONF_INPUT
 | 
			
		||||
from homeassistant.components.ffmpeg.camera import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    DEFAULT_ARGUMENTS,
 | 
			
		||||
    FFmpegCamera,
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -7,5 +7,5 @@
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/generic",
 | 
			
		||||
  "integration_type": "device",
 | 
			
		||||
  "iot_class": "local_push",
 | 
			
		||||
  "requirements": ["av==13.1.0", "Pillow==11.3.0"]
 | 
			
		||||
  "requirements": ["av==13.1.0", "Pillow==12.0.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -30,8 +30,8 @@ from homeassistant.components.camera import (
 | 
			
		||||
    WebRTCMessage,
 | 
			
		||||
    WebRTCSendMessage,
 | 
			
		||||
    async_register_webrtc_provider,
 | 
			
		||||
    get_dynamic_camera_stream_settings,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.camera.prefs import get_dynamic_camera_stream_settings
 | 
			
		||||
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
 | 
			
		||||
from homeassistant.components.stream import Orientation
 | 
			
		||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "domain": "growatt_server",
 | 
			
		||||
  "name": "Growatt",
 | 
			
		||||
  "codeowners": [],
 | 
			
		||||
  "codeowners": ["@johanzander"],
 | 
			
		||||
  "config_flow": true,
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/growatt_server",
 | 
			
		||||
  "iot_class": "cloud_polling",
 | 
			
		||||
 
 | 
			
		||||
@@ -7,5 +7,5 @@
 | 
			
		||||
  "iot_class": "cloud_polling",
 | 
			
		||||
  "loggers": ["habiticalib"],
 | 
			
		||||
  "quality_scale": "platinum",
 | 
			
		||||
  "requirements": ["habiticalib==0.4.5"]
 | 
			
		||||
  "requirements": ["habiticalib==0.4.6"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ from homeassistant.components.binary_sensor import (
 | 
			
		||||
    BinarySensorDeviceClass,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
 | 
			
		||||
from homeassistant.components.device_automation.trigger import (
 | 
			
		||||
from homeassistant.components.device_automation.trigger import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    async_validate_trigger_config,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
  "config_flow": true,
 | 
			
		||||
  "dependencies": ["application_credentials"],
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower",
 | 
			
		||||
  "integration_type": "hub",
 | 
			
		||||
  "iot_class": "cloud_push",
 | 
			
		||||
  "loggers": ["aioautomower"],
 | 
			
		||||
  "quality_scale": "silver",
 | 
			
		||||
 
 | 
			
		||||
@@ -7,5 +7,5 @@
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/image_upload",
 | 
			
		||||
  "integration_type": "system",
 | 
			
		||||
  "quality_scale": "internal",
 | 
			
		||||
  "requirements": ["Pillow==11.3.0"]
 | 
			
		||||
  "requirements": ["Pillow==12.0.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,12 @@
 | 
			
		||||
 | 
			
		||||
from typing import Any, Final
 | 
			
		||||
 | 
			
		||||
from iometer import IOmeterClient, IOmeterConnectionError
 | 
			
		||||
from iometer import (
 | 
			
		||||
    IOmeterClient,
 | 
			
		||||
    IOmeterConnectionError,
 | 
			
		||||
    IOmeterNoReadingsError,
 | 
			
		||||
    IOmeterNoStatusError,
 | 
			
		||||
)
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
 | 
			
		||||
@@ -34,6 +39,11 @@ class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
        client = IOmeterClient(host=host, session=session)
 | 
			
		||||
        try:
 | 
			
		||||
            status = await client.get_current_status()
 | 
			
		||||
            _ = await client.get_current_reading()
 | 
			
		||||
        except IOmeterNoStatusError:
 | 
			
		||||
            return self.async_abort(reason="no_status")
 | 
			
		||||
        except IOmeterNoReadingsError:
 | 
			
		||||
            return self.async_abort(reason="no_readings")
 | 
			
		||||
        except IOmeterConnectionError:
 | 
			
		||||
            return self.async_abort(reason="cannot_connect")
 | 
			
		||||
 | 
			
		||||
@@ -70,6 +80,11 @@ class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            client = IOmeterClient(host=self._host, session=session)
 | 
			
		||||
            try:
 | 
			
		||||
                status = await client.get_current_status()
 | 
			
		||||
                _ = await client.get_current_reading()
 | 
			
		||||
            except IOmeterNoStatusError:
 | 
			
		||||
                errors["base"] = "no_status"
 | 
			
		||||
            except IOmeterNoReadingsError:
 | 
			
		||||
                errors["base"] = "no_readings"
 | 
			
		||||
            except IOmeterConnectionError:
 | 
			
		||||
                errors["base"] = "cannot_connect"
 | 
			
		||||
            else:
 | 
			
		||||
 
 | 
			
		||||
@@ -20,6 +20,8 @@
 | 
			
		||||
      "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
 | 
			
		||||
    },
 | 
			
		||||
    "error": {
 | 
			
		||||
      "no_status": "No status received from the IOmeter. Check your device status in the IOmeter app",
 | 
			
		||||
      "no_readings": "No readings received from the IOmeter. Please attach the IOmeter Core to the electricity meter and wait for the first reading.",
 | 
			
		||||
      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
 | 
			
		||||
      "unknown": "[%key:common::config_flow::error::unknown%]"
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -31,6 +31,7 @@ class IPPEntity(CoordinatorEntity[IPPDataUpdateCoordinator]):
 | 
			
		||||
            manufacturer=self.coordinator.data.info.manufacturer,
 | 
			
		||||
            model=self.coordinator.data.info.model,
 | 
			
		||||
            name=self.coordinator.data.info.name,
 | 
			
		||||
            serial_number=self.coordinator.data.info.serial,
 | 
			
		||||
            sw_version=self.coordinator.data.info.version,
 | 
			
		||||
            configuration_url=self.coordinator.data.info.more_info,
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -358,7 +358,7 @@
 | 
			
		||||
            "entity_label": "Entity name",
 | 
			
		||||
            "entity_description": "Optional if a device is selected, otherwise required. If the entity is assigned to a device, the device name is used as prefix.",
 | 
			
		||||
            "entity_category_title": "Entity category",
 | 
			
		||||
            "entity_category_description": "Classification of a non-primary entity. Leave empty for standard behaviour."
 | 
			
		||||
            "entity_category_description": "Classification of a non-primary entity. Leave empty for standard behavior."
 | 
			
		||||
          },
 | 
			
		||||
          "knx": {
 | 
			
		||||
            "title": "KNX configuration",
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ from pychromecast import Chromecast
 | 
			
		||||
from pychromecast.const import CAST_TYPE_CHROMECAST
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.cast import DOMAIN as CAST_DOMAIN
 | 
			
		||||
from homeassistant.components.cast.home_assistant_cast import (
 | 
			
		||||
from homeassistant.components.cast.home_assistant_cast import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    ATTR_URL_PATH,
 | 
			
		||||
    ATTR_VIEW_PATH,
 | 
			
		||||
    NO_URL_AVAILABLE_ERROR,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,5 @@
 | 
			
		||||
  "iot_class": "cloud_push",
 | 
			
		||||
  "loggers": ["matrix_client"],
 | 
			
		||||
  "quality_scale": "legacy",
 | 
			
		||||
  "requirements": ["matrix-nio==0.25.2", "Pillow==11.3.0"]
 | 
			
		||||
  "requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -7,5 +7,5 @@
 | 
			
		||||
  "integration_type": "service",
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "quality_scale": "platinum",
 | 
			
		||||
  "requirements": ["aiomealie==1.0.0"]
 | 
			
		||||
  "requirements": ["aiomealie==1.0.1"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,9 +5,8 @@ from __future__ import annotations
 | 
			
		||||
import asyncio
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
import datapoint
 | 
			
		||||
import datapoint.Forecast
 | 
			
		||||
import datapoint.Manager
 | 
			
		||||
from datapoint.Forecast import Forecast
 | 
			
		||||
from datapoint.Manager import Manager
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.const import (
 | 
			
		||||
@@ -48,19 +47,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
 | 
			
		||||
    coordinates = f"{latitude}_{longitude}"
 | 
			
		||||
 | 
			
		||||
    connection = datapoint.Manager.Manager(api_key=api_key)
 | 
			
		||||
    connection = Manager(api_key=api_key)
 | 
			
		||||
 | 
			
		||||
    async def async_update_hourly() -> datapoint.Forecast:
 | 
			
		||||
    async def async_update_hourly() -> Forecast:
 | 
			
		||||
        return await hass.async_add_executor_job(
 | 
			
		||||
            fetch_data, connection, latitude, longitude, "hourly"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def async_update_daily() -> datapoint.Forecast:
 | 
			
		||||
    async def async_update_daily() -> Forecast:
 | 
			
		||||
        return await hass.async_add_executor_job(
 | 
			
		||||
            fetch_data, connection, latitude, longitude, "daily"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def async_update_twice_daily() -> datapoint.Forecast:
 | 
			
		||||
    async def async_update_twice_daily() -> Forecast:
 | 
			
		||||
        return await hass.async_add_executor_job(
 | 
			
		||||
            fetch_data, connection, latitude, longitude, "twice-daily"
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -6,9 +6,8 @@ from collections.abc import Mapping
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import datapoint
 | 
			
		||||
from datapoint.exceptions import APIException
 | 
			
		||||
import datapoint.Manager
 | 
			
		||||
from datapoint.Manager import Manager
 | 
			
		||||
from requests import HTTPError
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
@@ -31,7 +30,7 @@ async def validate_input(
 | 
			
		||||
    Data has the keys from DATA_SCHEMA with values provided by the user.
 | 
			
		||||
    """
 | 
			
		||||
    errors = {}
 | 
			
		||||
    connection = datapoint.Manager.Manager(api_key=api_key)
 | 
			
		||||
    connection = Manager(api_key=api_key)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        forecast = await hass.async_add_executor_job(
 | 
			
		||||
 
 | 
			
		||||
@@ -5,8 +5,9 @@ from __future__ import annotations
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Any, Literal
 | 
			
		||||
 | 
			
		||||
import datapoint
 | 
			
		||||
from datapoint.exceptions import APIException
 | 
			
		||||
from datapoint.Forecast import Forecast
 | 
			
		||||
from datapoint.Manager import Manager
 | 
			
		||||
from requests import HTTPError
 | 
			
		||||
 | 
			
		||||
from homeassistant.exceptions import ConfigEntryAuthFailed
 | 
			
		||||
@@ -16,7 +17,7 @@ _LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fetch_data(
 | 
			
		||||
    connection: datapoint.Manager,
 | 
			
		||||
    connection: Manager,
 | 
			
		||||
    latitude: float,
 | 
			
		||||
    longitude: float,
 | 
			
		||||
    frequency: Literal["daily", "twice-daily", "hourly"],
 | 
			
		||||
@@ -26,7 +27,7 @@ def fetch_data(
 | 
			
		||||
        return connection.get_forecast(
 | 
			
		||||
            latitude, longitude, frequency, convert_weather_code=False
 | 
			
		||||
        )
 | 
			
		||||
    except (ValueError, datapoint.exceptions.APIException) as err:
 | 
			
		||||
    except (ValueError, APIException) as err:
 | 
			
		||||
        _LOGGER.error("Check Met Office connection: %s", err.args)
 | 
			
		||||
        raise UpdateFailed from err
 | 
			
		||||
    except HTTPError as err:
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ from __future__ import annotations
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from typing import Any, cast
 | 
			
		||||
 | 
			
		||||
from datapoint.Forecast import Forecast as ForecastData
 | 
			
		||||
from datapoint.Forecast import Forecast
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.weather import (
 | 
			
		||||
    ATTR_FORECAST_CONDITION,
 | 
			
		||||
@@ -22,7 +22,7 @@ from homeassistant.components.weather import (
 | 
			
		||||
    ATTR_FORECAST_WIND_BEARING,
 | 
			
		||||
    DOMAIN as WEATHER_DOMAIN,
 | 
			
		||||
    CoordinatorWeatherEntity,
 | 
			
		||||
    Forecast,
 | 
			
		||||
    Forecast as WeatherForecast,
 | 
			
		||||
    WeatherEntityFeature,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
@@ -85,20 +85,20 @@ async def async_setup_entry(
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _build_hourly_forecast_data(timestep: dict[str, Any]) -> Forecast:
 | 
			
		||||
    data = Forecast(datetime=timestep["time"].isoformat())
 | 
			
		||||
def _build_hourly_forecast_data(timestep: dict[str, Any]) -> WeatherForecast:
 | 
			
		||||
    data = WeatherForecast(datetime=timestep["time"].isoformat())
 | 
			
		||||
    _populate_forecast_data(data, timestep, HOURLY_FORECAST_ATTRIBUTE_MAP)
 | 
			
		||||
    return data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _build_daily_forecast_data(timestep: dict[str, Any]) -> Forecast:
 | 
			
		||||
    data = Forecast(datetime=timestep["time"].isoformat())
 | 
			
		||||
def _build_daily_forecast_data(timestep: dict[str, Any]) -> WeatherForecast:
 | 
			
		||||
    data = WeatherForecast(datetime=timestep["time"].isoformat())
 | 
			
		||||
    _populate_forecast_data(data, timestep, DAILY_FORECAST_ATTRIBUTE_MAP)
 | 
			
		||||
    return data
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _build_twice_daily_forecast_data(timestep: dict[str, Any]) -> Forecast:
 | 
			
		||||
    data = Forecast(datetime=timestep["time"].isoformat())
 | 
			
		||||
def _build_twice_daily_forecast_data(timestep: dict[str, Any]) -> WeatherForecast:
 | 
			
		||||
    data = WeatherForecast(datetime=timestep["time"].isoformat())
 | 
			
		||||
 | 
			
		||||
    # day and night forecasts have slightly different format
 | 
			
		||||
    if "daySignificantWeatherCode" in timestep:
 | 
			
		||||
@@ -111,7 +111,7 @@ def _build_twice_daily_forecast_data(timestep: dict[str, Any]) -> Forecast:
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _populate_forecast_data(
 | 
			
		||||
    forecast: Forecast, timestep: dict[str, Any], mapping: dict[str, str]
 | 
			
		||||
    forecast: WeatherForecast, timestep: dict[str, Any], mapping: dict[str, str]
 | 
			
		||||
) -> None:
 | 
			
		||||
    def get_mapped_attribute(attr: str) -> Any:
 | 
			
		||||
        if attr not in mapping:
 | 
			
		||||
@@ -153,9 +153,9 @@ def _populate_forecast_data(
 | 
			
		||||
 | 
			
		||||
class MetOfficeWeather(
 | 
			
		||||
    CoordinatorWeatherEntity[
 | 
			
		||||
        TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
        TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
        TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
        TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
        TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
        TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
    ]
 | 
			
		||||
):
 | 
			
		||||
    """Implementation of a Met Office weather condition."""
 | 
			
		||||
@@ -177,9 +177,9 @@ class MetOfficeWeather(
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        coordinator_daily: TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
        coordinator_hourly: TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
        coordinator_twice_daily: TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
        coordinator_daily: TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
        coordinator_hourly: TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
        coordinator_twice_daily: TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
        hass_data: dict[str, Any],
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialise the platform with a data instance."""
 | 
			
		||||
@@ -263,10 +263,10 @@ class MetOfficeWeather(
 | 
			
		||||
        return float(value) if value is not None else None
 | 
			
		||||
 | 
			
		||||
    @callback
 | 
			
		||||
    def _async_forecast_daily(self) -> list[Forecast] | None:
 | 
			
		||||
    def _async_forecast_daily(self) -> list[WeatherForecast] | None:
 | 
			
		||||
        """Return the daily forecast in native units."""
 | 
			
		||||
        coordinator = cast(
 | 
			
		||||
            TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
            TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
            self.forecast_coordinators["daily"],
 | 
			
		||||
        )
 | 
			
		||||
        timesteps = coordinator.data.timesteps
 | 
			
		||||
@@ -277,10 +277,10 @@ class MetOfficeWeather(
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    @callback
 | 
			
		||||
    def _async_forecast_hourly(self) -> list[Forecast] | None:
 | 
			
		||||
    def _async_forecast_hourly(self) -> list[WeatherForecast] | None:
 | 
			
		||||
        """Return the hourly forecast in native units."""
 | 
			
		||||
        coordinator = cast(
 | 
			
		||||
            TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
            TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
            self.forecast_coordinators["hourly"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@@ -292,10 +292,10 @@ class MetOfficeWeather(
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    @callback
 | 
			
		||||
    def _async_forecast_twice_daily(self) -> list[Forecast] | None:
 | 
			
		||||
    def _async_forecast_twice_daily(self) -> list[WeatherForecast] | None:
 | 
			
		||||
        """Return the twice daily forecast in native units."""
 | 
			
		||||
        coordinator = cast(
 | 
			
		||||
            TimestampDataUpdateCoordinator[ForecastData],
 | 
			
		||||
            TimestampDataUpdateCoordinator[Forecast],
 | 
			
		||||
            self.forecast_coordinators["twice_daily"],
 | 
			
		||||
        )
 | 
			
		||||
        timesteps = coordinator.data.timesteps
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ from typing import Any
 | 
			
		||||
from homeassistant.components.diagnostics import async_redact_data
 | 
			
		||||
from homeassistant.const import CONF_HOST
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers import device_registry as dr
 | 
			
		||||
 | 
			
		||||
from .onewirehub import OneWireConfigEntry
 | 
			
		||||
 | 
			
		||||
@@ -26,7 +27,28 @@ async def async_get_config_entry_diagnostics(
 | 
			
		||||
            "data": async_redact_data(entry.data, TO_REDACT),
 | 
			
		||||
            "options": {**entry.options},
 | 
			
		||||
        },
 | 
			
		||||
        "devices": [asdict(device_details) for device_details in onewire_hub.devices]
 | 
			
		||||
        if onewire_hub.devices
 | 
			
		||||
        else [],
 | 
			
		||||
        "devices": [asdict(device_details) for device_details in onewire_hub.devices],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_get_device_diagnostics(
 | 
			
		||||
    hass: HomeAssistant, entry: OneWireConfigEntry, device_entry: dr.DeviceEntry
 | 
			
		||||
) -> dict[str, Any]:
 | 
			
		||||
    """Return diagnostics for a device."""
 | 
			
		||||
 | 
			
		||||
    onewire_hub = entry.runtime_data
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        "entry": {
 | 
			
		||||
            "title": entry.title,
 | 
			
		||||
            "data": async_redact_data(entry.data, TO_REDACT),
 | 
			
		||||
            "options": {**entry.options},
 | 
			
		||||
        },
 | 
			
		||||
        "device": asdict(
 | 
			
		||||
            next(
 | 
			
		||||
                device_details
 | 
			
		||||
                for device_details in onewire_hub.devices
 | 
			
		||||
                if device_details.id[3:] == device_entry.serial_number
 | 
			
		||||
            )
 | 
			
		||||
        ),
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@
 | 
			
		||||
  "integration_type": "hub",
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "loggers": ["aio_ownet"],
 | 
			
		||||
  "quality_scale": "silver",
 | 
			
		||||
  "requirements": ["aio-ownet==0.0.4"],
 | 
			
		||||
  "zeroconf": ["_owserver._tcp.local."]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,6 @@
 | 
			
		||||
rules:
 | 
			
		||||
  ## Bronze
 | 
			
		||||
  config-flow:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: missing data_description on options flow
 | 
			
		||||
  config-flow: done
 | 
			
		||||
  test-before-configure: done
 | 
			
		||||
  unique-config-entry:
 | 
			
		||||
    status: done
 | 
			
		||||
@@ -16,27 +14,19 @@ rules:
 | 
			
		||||
  entity-event-setup:
 | 
			
		||||
    status: exempt
 | 
			
		||||
    comment: entities do not subscribe to events
 | 
			
		||||
  dependency-transparency:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: The package is not built and published inside a CI pipeline
 | 
			
		||||
  dependency-transparency: done
 | 
			
		||||
  action-setup:
 | 
			
		||||
    status: exempt
 | 
			
		||||
    comment: No service actions currently available
 | 
			
		||||
  common-modules:
 | 
			
		||||
    status: done
 | 
			
		||||
    comment: base entity available, but no coordinator
 | 
			
		||||
  docs-high-level-description:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: Under review
 | 
			
		||||
  docs-installation-instructions:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: Under review
 | 
			
		||||
  docs-removal-instructions:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: Under review
 | 
			
		||||
  docs-high-level-description: done
 | 
			
		||||
  docs-installation-instructions: done
 | 
			
		||||
  docs-removal-instructions: done
 | 
			
		||||
  docs-actions:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: Under review
 | 
			
		||||
    status: exempt
 | 
			
		||||
    comment: No service actions currently available
 | 
			
		||||
  brands: done
 | 
			
		||||
 | 
			
		||||
  ## Silver
 | 
			
		||||
@@ -52,12 +42,8 @@ rules:
 | 
			
		||||
  parallel-updates: done
 | 
			
		||||
  test-coverage: done
 | 
			
		||||
  integration-owner: done
 | 
			
		||||
  docs-installation-parameters:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: Under review
 | 
			
		||||
  docs-configuration-parameters:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: Under review
 | 
			
		||||
  docs-installation-parameters: done
 | 
			
		||||
  docs-configuration-parameters: done
 | 
			
		||||
 | 
			
		||||
  ## Gold
 | 
			
		||||
  entity-translations: done
 | 
			
		||||
@@ -73,9 +59,7 @@ rules:
 | 
			
		||||
    comment: >
 | 
			
		||||
      Manual removal, as it is not possible to distinguish
 | 
			
		||||
      between a flaky device and a device that has been removed
 | 
			
		||||
  diagnostics:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: config-entry diagnostics level available, might be nice to have device-level diagnostics
 | 
			
		||||
  diagnostics: done
 | 
			
		||||
  exception-translations:
 | 
			
		||||
    status: todo
 | 
			
		||||
    comment: Under review
 | 
			
		||||
 
 | 
			
		||||
@@ -139,8 +139,12 @@
 | 
			
		||||
    "step": {
 | 
			
		||||
      "device_selection": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "clear_device_options": "Clear all device configurations",
 | 
			
		||||
          "device_selection": "[%key:component::onewire::options::error::device_not_selected%]"
 | 
			
		||||
          "clear_device_options": "Reset all device customizations",
 | 
			
		||||
          "device_selection": "Customize specific devices"
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "clear_device_options": "Use this to reset all device specific options to default values.",
 | 
			
		||||
          "device_selection": "Customize behavior of individual devices."
 | 
			
		||||
        },
 | 
			
		||||
        "description": "Select what configuration steps to process",
 | 
			
		||||
        "title": "1-Wire device options"
 | 
			
		||||
@@ -149,6 +153,9 @@
 | 
			
		||||
        "data": {
 | 
			
		||||
          "precision": "Sensor precision"
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "precision": "The lower the precision, the faster the sensor will respond, but with less accuracy."
 | 
			
		||||
        },
 | 
			
		||||
        "description": "Select sensor precision for {sensor_id}",
 | 
			
		||||
        "title": "1-Wire sensor precision"
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
 | 
			
		||||
    {
 | 
			
		||||
        vol.Required(CONF_HOST): str,
 | 
			
		||||
        vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
 | 
			
		||||
    }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def validate_input(hass: HomeAssistant, host: str, port: int) -> None:
 | 
			
		||||
    """Validate the user input allows us to connect."""
 | 
			
		||||
@@ -39,6 +46,48 @@ async def validate_input(hass: HomeAssistant, host: str, port: int) -> None:
 | 
			
		||||
class OpenRGBConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
    """Handle a config flow for OpenRGB."""
 | 
			
		||||
 | 
			
		||||
    async def async_step_reconfigure(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
        """Handle reconfiguration of the OpenRGB SDK Server."""
 | 
			
		||||
        reconfigure_entry = self._get_reconfigure_entry()
 | 
			
		||||
 | 
			
		||||
        errors: dict[str, str] = {}
 | 
			
		||||
        if user_input is not None:
 | 
			
		||||
            host = user_input[CONF_HOST]
 | 
			
		||||
            port = user_input[CONF_PORT]
 | 
			
		||||
 | 
			
		||||
            # Prevent duplicate entries
 | 
			
		||||
            self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
 | 
			
		||||
 | 
			
		||||
            try:
 | 
			
		||||
                await validate_input(self.hass, host, port)
 | 
			
		||||
            except CONNECTION_ERRORS:
 | 
			
		||||
                errors["base"] = "cannot_connect"
 | 
			
		||||
            except Exception:
 | 
			
		||||
                _LOGGER.exception(
 | 
			
		||||
                    "Unknown error while connecting to OpenRGB SDK server at %s",
 | 
			
		||||
                    f"{host}:{port}",
 | 
			
		||||
                )
 | 
			
		||||
                errors["base"] = "unknown"
 | 
			
		||||
            else:
 | 
			
		||||
                return self.async_update_reload_and_abort(
 | 
			
		||||
                    reconfigure_entry,
 | 
			
		||||
                    data_updates={CONF_HOST: host, CONF_PORT: port},
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        return self.async_show_form(
 | 
			
		||||
            step_id="reconfigure",
 | 
			
		||||
            data_schema=self.add_suggested_values_to_schema(
 | 
			
		||||
                data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
 | 
			
		||||
                suggested_values={
 | 
			
		||||
                    CONF_HOST: reconfigure_entry.data[CONF_HOST],
 | 
			
		||||
                    CONF_PORT: reconfigure_entry.data[CONF_PORT],
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            errors=errors,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def async_step_user(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
 
 | 
			
		||||
@@ -68,7 +68,7 @@ rules:
 | 
			
		||||
  entity-translations: todo
 | 
			
		||||
  exception-translations: done
 | 
			
		||||
  icon-translations: todo
 | 
			
		||||
  reconfiguration-flow: todo
 | 
			
		||||
  reconfiguration-flow: done
 | 
			
		||||
  repair-issues: todo
 | 
			
		||||
  stale-devices: todo
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@
 | 
			
		||||
  "config": {
 | 
			
		||||
    "step": {
 | 
			
		||||
      "user": {
 | 
			
		||||
        "title": "Set up OpenRGB SDK server",
 | 
			
		||||
        "description": "Set up your OpenRGB SDK server to allow control from within Home Assistant.",
 | 
			
		||||
        "data": {
 | 
			
		||||
          "name": "[%key:common::config_flow::data::name%]",
 | 
			
		||||
@@ -13,6 +14,18 @@
 | 
			
		||||
          "host": "The IP address or hostname of the computer running the OpenRGB SDK server.",
 | 
			
		||||
          "port": "The port number that the OpenRGB SDK server is running on."
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "reconfigure": {
 | 
			
		||||
        "title": "Reconfigure OpenRGB SDK server",
 | 
			
		||||
        "description": "Update the connection settings for your OpenRGB SDK server.",
 | 
			
		||||
        "data": {
 | 
			
		||||
          "host": "[%key:common::config_flow::data::host%]",
 | 
			
		||||
          "port": "[%key:common::config_flow::data::port%]"
 | 
			
		||||
        },
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "host": "[%key:component::openrgb::config::step::user::data_description::host%]",
 | 
			
		||||
          "port": "[%key:component::openrgb::config::step::user::data_description::port%]"
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "error": {
 | 
			
		||||
@@ -21,6 +34,7 @@
 | 
			
		||||
      "unknown": "[%key:common::config_flow::error::unknown%]"
 | 
			
		||||
    },
 | 
			
		||||
    "abort": {
 | 
			
		||||
      "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
 | 
			
		||||
      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,6 @@
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "loggers": ["plugwise"],
 | 
			
		||||
  "quality_scale": "platinum",
 | 
			
		||||
  "requirements": ["plugwise==1.8.0"],
 | 
			
		||||
  "requirements": ["plugwise==1.8.1"],
 | 
			
		||||
  "zeroconf": ["_plugwise._tcp.local."]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,5 +4,5 @@
 | 
			
		||||
  "codeowners": [],
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/proxy",
 | 
			
		||||
  "quality_scale": "legacy",
 | 
			
		||||
  "requirements": ["Pillow==11.3.0"]
 | 
			
		||||
  "requirements": ["Pillow==12.0.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,5 @@
 | 
			
		||||
  "iot_class": "calculated",
 | 
			
		||||
  "loggers": ["pyzbar"],
 | 
			
		||||
  "quality_scale": "legacy",
 | 
			
		||||
  "requirements": ["Pillow==11.3.0", "pyzbar==0.1.7"]
 | 
			
		||||
  "requirements": ["Pillow==12.0.0", "pyzbar==0.1.7"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -72,7 +72,7 @@ class RAPTPillConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
                title=self._discovered_devices[address], data={}
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        current_addresses = self._async_current_ids()
 | 
			
		||||
        current_addresses = self._async_current_ids(include_ignore=False)
 | 
			
		||||
        for discovery_info in async_discovered_service_info(self.hass, False):
 | 
			
		||||
            address = discovery_info.address
 | 
			
		||||
            if address in current_addresses or address in self._discovered_devices:
 | 
			
		||||
 
 | 
			
		||||
@@ -5,5 +5,5 @@
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/seven_segments",
 | 
			
		||||
  "iot_class": "local_polling",
 | 
			
		||||
  "quality_scale": "legacy",
 | 
			
		||||
  "requirements": ["Pillow==11.3.0"]
 | 
			
		||||
  "requirements": ["Pillow==12.0.0"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,5 +6,5 @@
 | 
			
		||||
  "iot_class": "cloud_polling",
 | 
			
		||||
  "loggers": ["simplehound"],
 | 
			
		||||
  "quality_scale": "legacy",
 | 
			
		||||
  "requirements": ["Pillow==11.3.0", "simplehound==0.3"]
 | 
			
		||||
  "requirements": ["Pillow==12.0.0", "simplehound==0.3"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,9 @@ from homeassistant.components.media_player import (
 | 
			
		||||
    async_process_play_media_url,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.plex import PLEX_URI_SCHEME
 | 
			
		||||
from homeassistant.components.plex.services import process_plex_payload
 | 
			
		||||
from homeassistant.components.plex.services import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    process_plex_payload,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
 | 
			
		||||
from homeassistant.helpers import entity_registry as er
 | 
			
		||||
 
 | 
			
		||||
@@ -441,9 +441,7 @@ class KeyFrameConverter:
 | 
			
		||||
 | 
			
		||||
        # Keep import here so that we can import stream integration
 | 
			
		||||
        # without installing reqs
 | 
			
		||||
        from homeassistant.components.camera.img_util import (  # noqa: PLC0415
 | 
			
		||||
            TurboJPEGSingleton,
 | 
			
		||||
        )
 | 
			
		||||
        from homeassistant.components.camera import TurboJPEGSingleton  # noqa: PLC0415
 | 
			
		||||
 | 
			
		||||
        self._packet: Packet | None = None
 | 
			
		||||
        self._event: asyncio.Event = asyncio.Event()
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ import io
 | 
			
		||||
import logging
 | 
			
		||||
from ssl import SSLContext
 | 
			
		||||
from types import MappingProxyType
 | 
			
		||||
from typing import Any
 | 
			
		||||
from typing import Any, cast
 | 
			
		||||
 | 
			
		||||
import httpx
 | 
			
		||||
from telegram import (
 | 
			
		||||
@@ -23,6 +23,7 @@ from telegram import (
 | 
			
		||||
    InputMediaVideo,
 | 
			
		||||
    InputPollOption,
 | 
			
		||||
    Message,
 | 
			
		||||
    PhotoSize,
 | 
			
		||||
    ReplyKeyboardMarkup,
 | 
			
		||||
    ReplyKeyboardRemove,
 | 
			
		||||
    Update,
 | 
			
		||||
@@ -56,6 +57,10 @@ from .const import (
 | 
			
		||||
    ATTR_DISABLE_NOTIF,
 | 
			
		||||
    ATTR_DISABLE_WEB_PREV,
 | 
			
		||||
    ATTR_FILE,
 | 
			
		||||
    ATTR_FILE_ID,
 | 
			
		||||
    ATTR_FILE_MIME_TYPE,
 | 
			
		||||
    ATTR_FILE_NAME,
 | 
			
		||||
    ATTR_FILE_SIZE,
 | 
			
		||||
    ATTR_FROM_FIRST,
 | 
			
		||||
    ATTR_FROM_LAST,
 | 
			
		||||
    ATTR_INLINE_MESSAGE_ID,
 | 
			
		||||
@@ -86,6 +91,7 @@ from .const import (
 | 
			
		||||
    CONF_CHAT_ID,
 | 
			
		||||
    CONF_PROXY_URL,
 | 
			
		||||
    DOMAIN,
 | 
			
		||||
    EVENT_TELEGRAM_ATTACHMENT,
 | 
			
		||||
    EVENT_TELEGRAM_CALLBACK,
 | 
			
		||||
    EVENT_TELEGRAM_COMMAND,
 | 
			
		||||
    EVENT_TELEGRAM_SENT,
 | 
			
		||||
@@ -183,6 +189,10 @@ class BaseTelegramBot:
 | 
			
		||||
            # This is a command message - set event type to command and split data into command and args
 | 
			
		||||
            event_type = EVENT_TELEGRAM_COMMAND
 | 
			
		||||
            event_data.update(self._get_command_event_data(message.text))
 | 
			
		||||
        elif filters.ATTACHMENT.filter(message):
 | 
			
		||||
            event_type = EVENT_TELEGRAM_ATTACHMENT
 | 
			
		||||
            event_data[ATTR_TEXT] = message.caption
 | 
			
		||||
            event_data.update(self._get_file_id_event_data(message))
 | 
			
		||||
        else:
 | 
			
		||||
            event_type = EVENT_TELEGRAM_TEXT
 | 
			
		||||
            event_data[ATTR_TEXT] = message.text
 | 
			
		||||
@@ -192,6 +202,26 @@ class BaseTelegramBot:
 | 
			
		||||
 | 
			
		||||
        return event_type, event_data
 | 
			
		||||
 | 
			
		||||
    def _get_file_id_event_data(self, message: Message) -> dict[str, Any]:
 | 
			
		||||
        """Extract file_id from a message attachment, if any."""
 | 
			
		||||
        if filters.PHOTO.filter(message):
 | 
			
		||||
            photos = cast(Sequence[PhotoSize], message.effective_attachment)
 | 
			
		||||
            return {
 | 
			
		||||
                ATTR_FILE_ID: photos[-1].file_id,
 | 
			
		||||
                ATTR_FILE_MIME_TYPE: "image/jpeg",  # telegram always uses jpeg for photos
 | 
			
		||||
                ATTR_FILE_SIZE: photos[-1].file_size,
 | 
			
		||||
            }
 | 
			
		||||
        return {
 | 
			
		||||
            k: getattr(message.effective_attachment, v)
 | 
			
		||||
            for k, v in (
 | 
			
		||||
                (ATTR_FILE_ID, "file_id"),
 | 
			
		||||
                (ATTR_FILE_NAME, "file_name"),
 | 
			
		||||
                (ATTR_FILE_MIME_TYPE, "mime_type"),
 | 
			
		||||
                (ATTR_FILE_SIZE, "file_size"),
 | 
			
		||||
            )
 | 
			
		||||
            if hasattr(message.effective_attachment, v)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def _get_user_event_data(self, user: User) -> dict[str, Any]:
 | 
			
		||||
        return {
 | 
			
		||||
            ATTR_USER_ID: user.id,
 | 
			
		||||
@@ -548,6 +578,7 @@ class TelegramNotificationService:
 | 
			
		||||
            "Error sending message",
 | 
			
		||||
            params[ATTR_MESSAGE_TAG],
 | 
			
		||||
            text,
 | 
			
		||||
            target=target,
 | 
			
		||||
            parse_mode=params[ATTR_PARSER],
 | 
			
		||||
            disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV],
 | 
			
		||||
            disable_notification=params[ATTR_DISABLE_NOTIF],
 | 
			
		||||
 
 | 
			
		||||
@@ -54,6 +54,7 @@ SERVICE_LEAVE_CHAT = "leave_chat"
 | 
			
		||||
EVENT_TELEGRAM_CALLBACK = "telegram_callback"
 | 
			
		||||
EVENT_TELEGRAM_COMMAND = "telegram_command"
 | 
			
		||||
EVENT_TELEGRAM_TEXT = "telegram_text"
 | 
			
		||||
EVENT_TELEGRAM_ATTACHMENT = "telegram_attachment"
 | 
			
		||||
EVENT_TELEGRAM_SENT = "telegram_sent"
 | 
			
		||||
 | 
			
		||||
PARSER_HTML = "html"
 | 
			
		||||
@@ -90,6 +91,10 @@ ATTR_DISABLE_NOTIF = "disable_notification"
 | 
			
		||||
ATTR_DISABLE_WEB_PREV = "disable_web_page_preview"
 | 
			
		||||
ATTR_EDITED_MSG = "edited_message"
 | 
			
		||||
ATTR_FILE = "file"
 | 
			
		||||
ATTR_FILE_ID = "file_id"
 | 
			
		||||
ATTR_FILE_MIME_TYPE = "file_mime_type"
 | 
			
		||||
ATTR_FILE_NAME = "file_name"
 | 
			
		||||
ATTR_FILE_SIZE = "file_size"
 | 
			
		||||
ATTR_FROM_FIRST = "from_first"
 | 
			
		||||
ATTR_FROM_LAST = "from_last"
 | 
			
		||||
ATTR_KEYBOARD = "keyboard"
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,6 @@
 | 
			
		||||
    "tf-models-official==2.5.0",
 | 
			
		||||
    "pycocotools==2.0.6",
 | 
			
		||||
    "numpy==2.3.2",
 | 
			
		||||
    "Pillow==11.3.0"
 | 
			
		||||
    "Pillow==12.0.0"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -254,10 +254,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
 | 
			
		||||
    def _is_position_reversed(self) -> bool:
 | 
			
		||||
        """Check if the cover position and direction should be reversed."""
 | 
			
		||||
        # The default is True
 | 
			
		||||
        # Having motor_reverse_mode == "back" cancels the inversion
 | 
			
		||||
        # Having motor_reverse_mode == "forward" cancels the inversion
 | 
			
		||||
        return not (
 | 
			
		||||
            self._motor_reverse_mode_enum
 | 
			
		||||
            and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back"
 | 
			
		||||
            and self.device.status.get(self._motor_reverse_mode_enum.dpcode)
 | 
			
		||||
            == "forward"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
 
 | 
			
		||||
@@ -13,5 +13,5 @@
 | 
			
		||||
  "documentation": "https://www.home-assistant.io/integrations/vesync",
 | 
			
		||||
  "iot_class": "cloud_polling",
 | 
			
		||||
  "loggers": ["pyvesync"],
 | 
			
		||||
  "requirements": ["pyvesync==3.1.0"]
 | 
			
		||||
  "requirements": ["pyvesync==3.1.2"]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -348,6 +348,12 @@
 | 
			
		||||
      "odometer": {
 | 
			
		||||
        "default": "mdi:counter"
 | 
			
		||||
      },
 | 
			
		||||
      "service_warning": {
 | 
			
		||||
        "default": "mdi:wrench-clock",
 | 
			
		||||
        "state": {
 | 
			
		||||
          "no_warning": "mdi:car-wrench"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "target_battery_charge_level": {
 | 
			
		||||
        "default": "mdi:battery-medium"
 | 
			
		||||
      },
 | 
			
		||||
 
 | 
			
		||||
@@ -332,6 +332,25 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = (
 | 
			
		||||
        state_class=SensorStateClass.TOTAL_INCREASING,
 | 
			
		||||
        suggested_display_precision=1,
 | 
			
		||||
    ),
 | 
			
		||||
    # diagnostics endpoint
 | 
			
		||||
    VolvoSensorDescription(
 | 
			
		||||
        key="service_warning",
 | 
			
		||||
        api_field="serviceWarning",
 | 
			
		||||
        device_class=SensorDeviceClass.ENUM,
 | 
			
		||||
        options=[
 | 
			
		||||
            "distance_driven_almost_time_for_service",
 | 
			
		||||
            "distance_driven_overdue_for_service",
 | 
			
		||||
            "distance_driven_time_for_service",
 | 
			
		||||
            "engine_hours_almost_time_for_service",
 | 
			
		||||
            "engine_hours_overdue_for_service",
 | 
			
		||||
            "engine_hours_time_for_service",
 | 
			
		||||
            "no_warning",
 | 
			
		||||
            "regular_maintenance_almost_time_for_service",
 | 
			
		||||
            "regular_maintenance_overdue_for_service",
 | 
			
		||||
            "regular_maintenance_time_for_service",
 | 
			
		||||
            "unknown_warning",
 | 
			
		||||
        ],
 | 
			
		||||
    ),
 | 
			
		||||
    # energy state endpoint
 | 
			
		||||
    VolvoSensorDescription(
 | 
			
		||||
        key="target_battery_charge_level",
 | 
			
		||||
 
 | 
			
		||||
@@ -309,6 +309,22 @@
 | 
			
		||||
      "odometer": {
 | 
			
		||||
        "name": "Odometer"
 | 
			
		||||
      },
 | 
			
		||||
      "service_warning": {
 | 
			
		||||
        "name": "Service",
 | 
			
		||||
        "state": {
 | 
			
		||||
          "distance_driven_almost_time_for_service": "Almost time for distance service",
 | 
			
		||||
          "distance_driven_overdue_for_service": "Distance service overdue",
 | 
			
		||||
          "distance_driven_time_for_service": "Time for distance service",
 | 
			
		||||
          "engine_hours_almost_time_for_service": "Almost time for engine service",
 | 
			
		||||
          "engine_hours_overdue_for_service": "Engine service overdue",
 | 
			
		||||
          "engine_hours_time_for_service": "Time for engine service",
 | 
			
		||||
          "no_warning": "No warning",
 | 
			
		||||
          "regular_maintenance_almost_time_for_service": "Almost time for service",
 | 
			
		||||
          "regular_maintenance_overdue_for_service": "Service overdue",
 | 
			
		||||
          "regular_maintenance_time_for_service": "Time for service",
 | 
			
		||||
          "unknown_warning": "Unknown warning"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "target_battery_charge_level": {
 | 
			
		||||
        "name": "Target battery charge level"
 | 
			
		||||
      },
 | 
			
		||||
 
 | 
			
		||||
@@ -2,32 +2,169 @@
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
from types import MappingProxyType
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
 | 
			
		||||
from aiowaqi import WAQIClient
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
 | 
			
		||||
from homeassistant.const import CONF_API_KEY, Platform
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers import (
 | 
			
		||||
    config_validation as cv,
 | 
			
		||||
    device_registry as dr,
 | 
			
		||||
    entity_registry as er,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
 | 
			
		||||
from homeassistant.helpers.typing import ConfigType
 | 
			
		||||
 | 
			
		||||
from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION
 | 
			
		||||
from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
 | 
			
		||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
 | 
			
		||||
    """Set up WAQI."""
 | 
			
		||||
 | 
			
		||||
    await async_migrate_integration(hass)
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool:
 | 
			
		||||
    """Set up World Air Quality Index (WAQI) from a config entry."""
 | 
			
		||||
 | 
			
		||||
    client = WAQIClient(session=async_get_clientsession(hass))
 | 
			
		||||
    client.authenticate(entry.data[CONF_API_KEY])
 | 
			
		||||
 | 
			
		||||
    waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client)
 | 
			
		||||
    await waqi_coordinator.async_config_entry_first_refresh()
 | 
			
		||||
    entry.runtime_data = waqi_coordinator
 | 
			
		||||
    entry.runtime_data = {}
 | 
			
		||||
 | 
			
		||||
    for subentry in entry.subentries.values():
 | 
			
		||||
        if subentry.subentry_type != SUBENTRY_TYPE_STATION:
 | 
			
		||||
            continue
 | 
			
		||||
 | 
			
		||||
        # Create a coordinator for each station subentry
 | 
			
		||||
        coordinator = WAQIDataUpdateCoordinator(hass, entry, subentry, client)
 | 
			
		||||
        await coordinator.async_config_entry_first_refresh()
 | 
			
		||||
        entry.runtime_data[subentry.subentry_id] = coordinator
 | 
			
		||||
 | 
			
		||||
    entry.async_on_unload(entry.add_update_listener(async_update_entry))
 | 
			
		||||
 | 
			
		||||
    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_update_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> None:
 | 
			
		||||
    """Update entry."""
 | 
			
		||||
    await hass.config_entries.async_reload(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool:
 | 
			
		||||
    """Unload a config entry."""
 | 
			
		||||
    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
 | 
			
		||||
    """Migrate integration entry structure to subentries."""
 | 
			
		||||
 | 
			
		||||
    # Make sure we get enabled config entries first
 | 
			
		||||
    entries = sorted(
 | 
			
		||||
        hass.config_entries.async_entries(DOMAIN),
 | 
			
		||||
        key=lambda e: e.disabled_by is not None,
 | 
			
		||||
    )
 | 
			
		||||
    if not any(entry.version == 1 for entry in entries):
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
 | 
			
		||||
    entity_registry = er.async_get(hass)
 | 
			
		||||
    device_registry = dr.async_get(hass)
 | 
			
		||||
 | 
			
		||||
    for entry in entries:
 | 
			
		||||
        subentry = ConfigSubentry(
 | 
			
		||||
            data=MappingProxyType(
 | 
			
		||||
                {CONF_STATION_NUMBER: entry.data[CONF_STATION_NUMBER]}
 | 
			
		||||
            ),
 | 
			
		||||
            subentry_type="station",
 | 
			
		||||
            title=entry.title,
 | 
			
		||||
            unique_id=entry.unique_id,
 | 
			
		||||
        )
 | 
			
		||||
        if entry.data[CONF_API_KEY] not in api_keys_entries:
 | 
			
		||||
            all_disabled = all(
 | 
			
		||||
                e.disabled_by is not None
 | 
			
		||||
                for e in entries
 | 
			
		||||
                if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
 | 
			
		||||
            )
 | 
			
		||||
            api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
 | 
			
		||||
 | 
			
		||||
        parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
 | 
			
		||||
 | 
			
		||||
        hass.config_entries.async_add_subentry(parent_entry, subentry)
 | 
			
		||||
 | 
			
		||||
        entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
 | 
			
		||||
        if TYPE_CHECKING:
 | 
			
		||||
            assert entry.unique_id is not None
 | 
			
		||||
        device = device_registry.async_get_device(
 | 
			
		||||
            identifiers={(DOMAIN, entry.unique_id)}
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        for entity_entry in entities:
 | 
			
		||||
            entity_disabled_by = entity_entry.disabled_by
 | 
			
		||||
            if (
 | 
			
		||||
                entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
 | 
			
		||||
                and not all_disabled
 | 
			
		||||
            ):
 | 
			
		||||
                # Device and entity registries don't update the disabled_by flag
 | 
			
		||||
                # when moving a device or entity from one config entry to another,
 | 
			
		||||
                # so we need to do it manually.
 | 
			
		||||
                entity_disabled_by = (
 | 
			
		||||
                    er.RegistryEntryDisabler.DEVICE
 | 
			
		||||
                    if device
 | 
			
		||||
                    else er.RegistryEntryDisabler.USER
 | 
			
		||||
                )
 | 
			
		||||
            entity_registry.async_update_entity(
 | 
			
		||||
                entity_entry.entity_id,
 | 
			
		||||
                config_entry_id=parent_entry.entry_id,
 | 
			
		||||
                config_subentry_id=subentry.subentry_id,
 | 
			
		||||
                disabled_by=entity_disabled_by,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        if device is not None:
 | 
			
		||||
            # Device and entity registries don't update the disabled_by flag when
 | 
			
		||||
            # moving a device or entity from one config entry to another, so we
 | 
			
		||||
            # need to do it manually.
 | 
			
		||||
            device_disabled_by = device.disabled_by
 | 
			
		||||
            if (
 | 
			
		||||
                device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
 | 
			
		||||
                and not all_disabled
 | 
			
		||||
            ):
 | 
			
		||||
                device_disabled_by = dr.DeviceEntryDisabler.USER
 | 
			
		||||
            device_registry.async_update_device(
 | 
			
		||||
                device.id,
 | 
			
		||||
                disabled_by=device_disabled_by,
 | 
			
		||||
                add_config_subentry_id=subentry.subentry_id,
 | 
			
		||||
                add_config_entry_id=parent_entry.entry_id,
 | 
			
		||||
            )
 | 
			
		||||
            if parent_entry.entry_id != entry.entry_id:
 | 
			
		||||
                device_registry.async_update_device(
 | 
			
		||||
                    device.id,
 | 
			
		||||
                    remove_config_entry_id=entry.entry_id,
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
                device_registry.async_update_device(
 | 
			
		||||
                    device.id,
 | 
			
		||||
                    remove_config_entry_id=entry.entry_id,
 | 
			
		||||
                    remove_config_subentry_id=None,
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        if parent_entry.entry_id != entry.entry_id:
 | 
			
		||||
            await hass.config_entries.async_remove(entry.entry_id)
 | 
			
		||||
        else:
 | 
			
		||||
            hass.config_entries.async_update_entry(
 | 
			
		||||
                entry,
 | 
			
		||||
                title="WAQI",
 | 
			
		||||
                version=2,
 | 
			
		||||
                data={CONF_API_KEY: entry.data[CONF_API_KEY]},
 | 
			
		||||
                unique_id=None,
 | 
			
		||||
            )
 | 
			
		||||
 
 | 
			
		||||
@@ -13,22 +13,24 @@ from aiowaqi import (
 | 
			
		||||
)
 | 
			
		||||
import voluptuous as vol
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
 | 
			
		||||
from homeassistant.config_entries import (
 | 
			
		||||
    ConfigEntry,
 | 
			
		||||
    ConfigFlow,
 | 
			
		||||
    ConfigFlowResult,
 | 
			
		||||
    ConfigSubentryFlow,
 | 
			
		||||
    SubentryFlowResult,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.const import (
 | 
			
		||||
    CONF_API_KEY,
 | 
			
		||||
    CONF_LATITUDE,
 | 
			
		||||
    CONF_LOCATION,
 | 
			
		||||
    CONF_LONGITUDE,
 | 
			
		||||
    CONF_METHOD,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.core import callback
 | 
			
		||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
 | 
			
		||||
from homeassistant.helpers.selector import (
 | 
			
		||||
    LocationSelector,
 | 
			
		||||
    SelectSelector,
 | 
			
		||||
    SelectSelectorConfig,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.helpers.selector import LocationSelector
 | 
			
		||||
 | 
			
		||||
from .const import CONF_STATION_NUMBER, DOMAIN
 | 
			
		||||
from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -54,11 +56,15 @@ async def get_by_station_number(
 | 
			
		||||
class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
    """Handle a config flow for World Air Quality Index (WAQI)."""
 | 
			
		||||
 | 
			
		||||
    VERSION = 1
 | 
			
		||||
    VERSION = 2
 | 
			
		||||
 | 
			
		||||
    def __init__(self) -> None:
 | 
			
		||||
        """Initialize config flow."""
 | 
			
		||||
        self.data: dict[str, Any] = {}
 | 
			
		||||
    @classmethod
 | 
			
		||||
    @callback
 | 
			
		||||
    def async_get_supported_subentry_types(
 | 
			
		||||
        cls, config_entry: ConfigEntry
 | 
			
		||||
    ) -> dict[str, type[ConfigSubentryFlow]]:
 | 
			
		||||
        """Return subentries supported by this handler."""
 | 
			
		||||
        return {SUBENTRY_TYPE_STATION: StationFlowHandler}
 | 
			
		||||
 | 
			
		||||
    async def async_step_user(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
@@ -66,6 +72,7 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
        """Handle the initial step."""
 | 
			
		||||
        errors: dict[str, str] = {}
 | 
			
		||||
        if user_input is not None:
 | 
			
		||||
            self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]})
 | 
			
		||||
            client = WAQIClient(session=async_get_clientsession(self.hass))
 | 
			
		||||
            client.authenticate(user_input[CONF_API_KEY])
 | 
			
		||||
            try:
 | 
			
		||||
@@ -78,35 +85,40 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
                _LOGGER.exception("Unexpected exception")
 | 
			
		||||
                errors["base"] = "unknown"
 | 
			
		||||
            else:
 | 
			
		||||
                self.data = user_input
 | 
			
		||||
                if user_input[CONF_METHOD] == CONF_MAP:
 | 
			
		||||
                    return await self.async_step_map()
 | 
			
		||||
                return await self.async_step_station_number()
 | 
			
		||||
                return self.async_create_entry(
 | 
			
		||||
                    title="World Air Quality Index",
 | 
			
		||||
                    data={
 | 
			
		||||
                        CONF_API_KEY: user_input[CONF_API_KEY],
 | 
			
		||||
                    },
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        return self.async_show_form(
 | 
			
		||||
            step_id="user",
 | 
			
		||||
            data_schema=vol.Schema(
 | 
			
		||||
                {
 | 
			
		||||
                    vol.Required(CONF_API_KEY): str,
 | 
			
		||||
                    vol.Required(CONF_METHOD): SelectSelector(
 | 
			
		||||
                        SelectSelectorConfig(
 | 
			
		||||
                            options=[CONF_MAP, CONF_STATION_NUMBER],
 | 
			
		||||
                            translation_key="method",
 | 
			
		||||
                        )
 | 
			
		||||
                    ),
 | 
			
		||||
                }
 | 
			
		||||
            ),
 | 
			
		||||
            data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
 | 
			
		||||
            errors=errors,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StationFlowHandler(ConfigSubentryFlow):
 | 
			
		||||
    """Handle subentry flow."""
 | 
			
		||||
 | 
			
		||||
    async def async_step_user(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> SubentryFlowResult:
 | 
			
		||||
        """User flow to create a sensor subentry."""
 | 
			
		||||
        return self.async_show_menu(
 | 
			
		||||
            step_id="user",
 | 
			
		||||
            menu_options=["map", "station_number"],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def async_step_map(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
    ) -> SubentryFlowResult:
 | 
			
		||||
        """Add measuring station via map."""
 | 
			
		||||
        errors: dict[str, str] = {}
 | 
			
		||||
        if user_input is not None:
 | 
			
		||||
            client = WAQIClient(session=async_get_clientsession(self.hass))
 | 
			
		||||
            client.authenticate(self.data[CONF_API_KEY])
 | 
			
		||||
            client.authenticate(self._get_entry().data[CONF_API_KEY])
 | 
			
		||||
            try:
 | 
			
		||||
                measuring_station = await client.get_by_coordinates(
 | 
			
		||||
                    user_input[CONF_LOCATION][CONF_LATITUDE],
 | 
			
		||||
@@ -124,9 +136,7 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
            data_schema=self.add_suggested_values_to_schema(
 | 
			
		||||
                vol.Schema(
 | 
			
		||||
                    {
 | 
			
		||||
                        vol.Required(
 | 
			
		||||
                            CONF_LOCATION,
 | 
			
		||||
                        ): LocationSelector(),
 | 
			
		||||
                        vol.Required(CONF_LOCATION): LocationSelector(),
 | 
			
		||||
                    }
 | 
			
		||||
                ),
 | 
			
		||||
                {
 | 
			
		||||
@@ -141,12 +151,12 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
 | 
			
		||||
    async def async_step_station_number(
 | 
			
		||||
        self, user_input: dict[str, Any] | None = None
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
    ) -> SubentryFlowResult:
 | 
			
		||||
        """Add measuring station via station number."""
 | 
			
		||||
        errors: dict[str, str] = {}
 | 
			
		||||
        if user_input is not None:
 | 
			
		||||
            client = WAQIClient(session=async_get_clientsession(self.hass))
 | 
			
		||||
            client.authenticate(self.data[CONF_API_KEY])
 | 
			
		||||
            client.authenticate(self._get_entry().data[CONF_API_KEY])
 | 
			
		||||
            station_number = user_input[CONF_STATION_NUMBER]
 | 
			
		||||
            measuring_station, errors = await get_by_station_number(
 | 
			
		||||
                client, abs(station_number)
 | 
			
		||||
@@ -160,25 +170,22 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
 | 
			
		||||
                return await self._async_create_entry(measuring_station)
 | 
			
		||||
        return self.async_show_form(
 | 
			
		||||
            step_id=CONF_STATION_NUMBER,
 | 
			
		||||
            data_schema=vol.Schema(
 | 
			
		||||
                {
 | 
			
		||||
                    vol.Required(
 | 
			
		||||
                        CONF_STATION_NUMBER,
 | 
			
		||||
                    ): int,
 | 
			
		||||
                }
 | 
			
		||||
            ),
 | 
			
		||||
            data_schema=vol.Schema({vol.Required(CONF_STATION_NUMBER): int}),
 | 
			
		||||
            errors=errors,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    async def _async_create_entry(
 | 
			
		||||
        self, measuring_station: WAQIAirQuality
 | 
			
		||||
    ) -> ConfigFlowResult:
 | 
			
		||||
        await self.async_set_unique_id(str(measuring_station.station_id))
 | 
			
		||||
        self._abort_if_unique_id_configured()
 | 
			
		||||
    ) -> SubentryFlowResult:
 | 
			
		||||
        station_id = str(measuring_station.station_id)
 | 
			
		||||
        for entry in self.hass.config_entries.async_entries(DOMAIN):
 | 
			
		||||
            for subentry in entry.subentries.values():
 | 
			
		||||
                if subentry.unique_id == station_id:
 | 
			
		||||
                    return self.async_abort(reason="already_configured")
 | 
			
		||||
        return self.async_create_entry(
 | 
			
		||||
            title=measuring_station.city.name,
 | 
			
		||||
            data={
 | 
			
		||||
                CONF_API_KEY: self.data[CONF_API_KEY],
 | 
			
		||||
                CONF_STATION_NUMBER: measuring_station.station_id,
 | 
			
		||||
            },
 | 
			
		||||
            unique_id=station_id,
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -8,4 +8,4 @@ LOGGER = logging.getLogger(__package__)
 | 
			
		||||
 | 
			
		||||
CONF_STATION_NUMBER = "station_number"
 | 
			
		||||
 | 
			
		||||
ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=waqi"}
 | 
			
		||||
SUBENTRY_TYPE_STATION = "station"
 | 
			
		||||
 
 | 
			
		||||
@@ -6,13 +6,13 @@ from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
 | 
			
		||||
 | 
			
		||||
from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER
 | 
			
		||||
from .const import CONF_STATION_NUMBER, LOGGER
 | 
			
		||||
 | 
			
		||||
type WAQIConfigEntry = ConfigEntry[WAQIDataUpdateCoordinator]
 | 
			
		||||
type WAQIConfigEntry = ConfigEntry[dict[str, WAQIDataUpdateCoordinator]]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]):
 | 
			
		||||
@@ -21,22 +21,27 @@ class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]):
 | 
			
		||||
    config_entry: WAQIConfigEntry
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self, hass: HomeAssistant, config_entry: WAQIConfigEntry, client: WAQIClient
 | 
			
		||||
        self,
 | 
			
		||||
        hass: HomeAssistant,
 | 
			
		||||
        config_entry: WAQIConfigEntry,
 | 
			
		||||
        subentry: ConfigSubentry,
 | 
			
		||||
        client: WAQIClient,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialize the WAQI data coordinator."""
 | 
			
		||||
        super().__init__(
 | 
			
		||||
            hass,
 | 
			
		||||
            LOGGER,
 | 
			
		||||
            config_entry=config_entry,
 | 
			
		||||
            name=DOMAIN,
 | 
			
		||||
            name=subentry.title,
 | 
			
		||||
            update_interval=timedelta(minutes=5),
 | 
			
		||||
        )
 | 
			
		||||
        self._client = client
 | 
			
		||||
        self.subentry = subentry
 | 
			
		||||
 | 
			
		||||
    async def _async_update_data(self) -> WAQIAirQuality:
 | 
			
		||||
        try:
 | 
			
		||||
            return await self._client.get_by_station_number(
 | 
			
		||||
                self.config_entry.data[CONF_STATION_NUMBER]
 | 
			
		||||
                self.subentry.data[CONF_STATION_NUMBER]
 | 
			
		||||
            )
 | 
			
		||||
        except WAQIError as exc:
 | 
			
		||||
            raise UpdateFailed from exc
 | 
			
		||||
 
 | 
			
		||||
@@ -130,11 +130,14 @@ async def async_setup_entry(
 | 
			
		||||
    async_add_entities: AddConfigEntryEntitiesCallback,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Set up the WAQI sensor."""
 | 
			
		||||
    coordinator = entry.runtime_data
 | 
			
		||||
    for subentry_id, coordinator in entry.runtime_data.items():
 | 
			
		||||
        async_add_entities(
 | 
			
		||||
            (
 | 
			
		||||
                WaqiSensor(coordinator, sensor)
 | 
			
		||||
                for sensor in SENSORS
 | 
			
		||||
                if sensor.available_fn(coordinator.data)
 | 
			
		||||
            ),
 | 
			
		||||
            config_subentry_id=subentry_id,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,19 +3,10 @@
 | 
			
		||||
    "step": {
 | 
			
		||||
      "user": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "api_key": "[%key:common::config_flow::data::api_key%]",
 | 
			
		||||
          "method": "How do you want to select a measuring station?"
 | 
			
		||||
        }
 | 
			
		||||
          "api_key": "[%key:common::config_flow::data::api_key%]"
 | 
			
		||||
        },
 | 
			
		||||
      "map": {
 | 
			
		||||
        "description": "Select a location to get the closest measuring station.",
 | 
			
		||||
        "data": {
 | 
			
		||||
          "location": "[%key:common::config_flow::data::location%]"
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "station_number": {
 | 
			
		||||
        "data": {
 | 
			
		||||
          "station_number": "Measuring station number"
 | 
			
		||||
        "data_description": {
 | 
			
		||||
          "api_key": "API key for the World Air Quality Index"
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
@@ -25,15 +16,44 @@
 | 
			
		||||
      "unknown": "[%key:common::config_flow::error::unknown%]"
 | 
			
		||||
    },
 | 
			
		||||
    "abort": {
 | 
			
		||||
      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
 | 
			
		||||
      "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "selector": {
 | 
			
		||||
    "method": {
 | 
			
		||||
      "options": {
 | 
			
		||||
        "map": "Select nearest from point on the map",
 | 
			
		||||
        "station_number": "Enter a station number"
 | 
			
		||||
  "config_subentries": {
 | 
			
		||||
    "station": {
 | 
			
		||||
      "step": {
 | 
			
		||||
        "user": {
 | 
			
		||||
          "title": "Add measuring station",
 | 
			
		||||
          "description": "How do you want to select a measuring station?",
 | 
			
		||||
          "menu_options": {
 | 
			
		||||
            "map": "[%key:common::config_flow::data::location%]",
 | 
			
		||||
            "station_number": "Measuring station number"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "map": {
 | 
			
		||||
          "data": {
 | 
			
		||||
            "location": "[%key:common::config_flow::data::location%]"
 | 
			
		||||
          },
 | 
			
		||||
          "data_description": {
 | 
			
		||||
            "location": "The location to get the nearest measuring station from"
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "station_number": {
 | 
			
		||||
          "data": {
 | 
			
		||||
            "station_number": "[%key:component::waqi::config_subentries::station::step::user::menu_options::station_number%]"
 | 
			
		||||
          },
 | 
			
		||||
          "data_description": {
 | 
			
		||||
            "station_number": "The number of the measuring station"
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "abort": {
 | 
			
		||||
        "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
 | 
			
		||||
      },
 | 
			
		||||
      "initiate_flow": {
 | 
			
		||||
        "user": "Add measuring station"
 | 
			
		||||
      },
 | 
			
		||||
      "entry_type": "Measuring station"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "entity": {
 | 
			
		||||
 
 | 
			
		||||
@@ -8,14 +8,13 @@ from xbox.webapi.api.client import XboxLiveClient
 | 
			
		||||
from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList
 | 
			
		||||
from xbox.webapi.common.signed_session import SignedSession
 | 
			
		||||
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.const import Platform
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
 | 
			
		||||
 | 
			
		||||
from . import api
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
from .coordinator import XboxUpdateCoordinator
 | 
			
		||||
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -29,7 +28,7 @@ PLATFORMS = [
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool:
 | 
			
		||||
    """Set up xbox from a config entry."""
 | 
			
		||||
    implementation = (
 | 
			
		||||
        await config_entry_oauth2_flow.async_get_config_entry_implementation(
 | 
			
		||||
@@ -45,30 +44,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
    _LOGGER.debug(
 | 
			
		||||
        "Found %d consoles: %s",
 | 
			
		||||
        len(consoles.result),
 | 
			
		||||
        consoles.dict(),
 | 
			
		||||
        consoles.model_dump(),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    coordinator = XboxUpdateCoordinator(hass, entry, client, consoles)
 | 
			
		||||
    await coordinator.async_config_entry_first_refresh()
 | 
			
		||||
 | 
			
		||||
    hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
 | 
			
		||||
        "client": XboxLiveClient(auth),
 | 
			
		||||
        "consoles": consoles,
 | 
			
		||||
        "coordinator": coordinator,
 | 
			
		||||
    }
 | 
			
		||||
    entry.runtime_data = coordinator
 | 
			
		||||
 | 
			
		||||
    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
 | 
			
		||||
 | 
			
		||||
    return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
 | 
			
		||||
async def async_unload_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool:
 | 
			
		||||
    """Unload a config entry."""
 | 
			
		||||
    unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 | 
			
		||||
    if unload_ok:
 | 
			
		||||
        # Unsub from coordinator updates
 | 
			
		||||
        hass.data[DOMAIN][entry.entry_id]["sensor_unsub"]()
 | 
			
		||||
        hass.data[DOMAIN][entry.entry_id]["binary_sensor_unsub"]()
 | 
			
		||||
        hass.data[DOMAIN].pop(entry.entry_id)
 | 
			
		||||
 | 
			
		||||
    return unload_ok
 | 
			
		||||
    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
 | 
			
		||||
 
 | 
			
		||||
@@ -5,13 +5,11 @@ from __future__ import annotations
 | 
			
		||||
from functools import partial
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.binary_sensor import BinarySensorEntity
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
from homeassistant.helpers import entity_registry as er
 | 
			
		||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
from .coordinator import XboxUpdateCoordinator
 | 
			
		||||
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
 | 
			
		||||
from .entity import XboxBaseEntity
 | 
			
		||||
 | 
			
		||||
PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"]
 | 
			
		||||
@@ -19,18 +17,16 @@ PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"]
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    entry: ConfigEntry,
 | 
			
		||||
    entry: XboxConfigEntry,
 | 
			
		||||
    async_add_entities: AddConfigEntryEntitiesCallback,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Set up Xbox Live friends."""
 | 
			
		||||
    coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
 | 
			
		||||
        "coordinator"
 | 
			
		||||
    ]
 | 
			
		||||
    coordinator = entry.runtime_data
 | 
			
		||||
 | 
			
		||||
    update_friends = partial(async_update_friends, coordinator, {}, async_add_entities)
 | 
			
		||||
 | 
			
		||||
    unsub = coordinator.async_add_listener(update_friends)
 | 
			
		||||
    hass.data[DOMAIN][entry.entry_id]["binary_sensor_unsub"] = unsub
 | 
			
		||||
    entry.async_on_unload(coordinator.async_add_listener(update_friends))
 | 
			
		||||
 | 
			
		||||
    update_friends()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -28,6 +28,8 @@ from .const import DOMAIN
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
type XboxConfigEntry = ConfigEntry[XboxUpdateCoordinator]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class ConsoleData:
 | 
			
		||||
 
 | 
			
		||||
@@ -5,13 +5,11 @@ from __future__ import annotations
 | 
			
		||||
import re
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from xbox.webapi.api.client import XboxLiveClient
 | 
			
		||||
from xbox.webapi.api.provider.catalog.models import Image
 | 
			
		||||
from xbox.webapi.api.provider.smartglass.models import (
 | 
			
		||||
    PlaybackState,
 | 
			
		||||
    PowerState,
 | 
			
		||||
    SmartglassConsole,
 | 
			
		||||
    SmartglassConsoleList,
 | 
			
		||||
    VolumeDirection,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -21,7 +19,6 @@ from homeassistant.components.media_player import (
 | 
			
		||||
    MediaPlayerState,
 | 
			
		||||
    MediaType,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.device_registry import DeviceInfo
 | 
			
		||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
@@ -29,7 +26,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
 | 
			
		||||
 | 
			
		||||
from .browse_media import build_item_response
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
from .coordinator import ConsoleData, XboxUpdateCoordinator
 | 
			
		||||
from .coordinator import ConsoleData, XboxConfigEntry, XboxUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
SUPPORT_XBOX = (
 | 
			
		||||
    MediaPlayerEntityFeature.TURN_ON
 | 
			
		||||
@@ -57,18 +54,18 @@ XBOX_STATE_MAP: dict[PlaybackState | PowerState, MediaPlayerState | None] = {
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    entry: ConfigEntry,
 | 
			
		||||
    entry: XboxConfigEntry,
 | 
			
		||||
    async_add_entities: AddConfigEntryEntitiesCallback,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Set up Xbox media_player from a config entry."""
 | 
			
		||||
    client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"]
 | 
			
		||||
    consoles: SmartglassConsoleList = hass.data[DOMAIN][entry.entry_id]["consoles"]
 | 
			
		||||
    coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
 | 
			
		||||
        "coordinator"
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    coordinator = entry.runtime_data
 | 
			
		||||
 | 
			
		||||
    async_add_entities(
 | 
			
		||||
        [XboxMediaPlayer(client, console, coordinator) for console in consoles.result]
 | 
			
		||||
        [
 | 
			
		||||
            XboxMediaPlayer(console, coordinator)
 | 
			
		||||
            for console in coordinator.consoles.result
 | 
			
		||||
        ]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -77,14 +74,13 @@ class XboxMediaPlayer(CoordinatorEntity[XboxUpdateCoordinator], MediaPlayerEntit
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        client: XboxLiveClient,
 | 
			
		||||
        console: SmartglassConsole,
 | 
			
		||||
        coordinator: XboxUpdateCoordinator,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialize the Xbox Media Player."""
 | 
			
		||||
        super().__init__(coordinator)
 | 
			
		||||
        self.client: XboxLiveClient = client
 | 
			
		||||
        self._console: SmartglassConsole = console
 | 
			
		||||
        self.client = coordinator.client
 | 
			
		||||
        self._console = console
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -24,6 +24,7 @@ from homeassistant.util import dt as dt_util
 | 
			
		||||
 | 
			
		||||
from .browse_media import _find_media_image
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
from .coordinator import XboxConfigEntry
 | 
			
		||||
 | 
			
		||||
MIME_TYPE_MAP = {
 | 
			
		||||
    "gameclips": "video/mp4",
 | 
			
		||||
@@ -38,8 +39,8 @@ MEDIA_CLASS_MAP = {
 | 
			
		||||
 | 
			
		||||
async def async_get_media_source(hass: HomeAssistant):
 | 
			
		||||
    """Set up Xbox media source."""
 | 
			
		||||
    entry = hass.config_entries.async_entries(DOMAIN)[0]
 | 
			
		||||
    client = hass.data[DOMAIN][entry.entry_id]["client"]
 | 
			
		||||
    entry: XboxConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
 | 
			
		||||
    client = entry.runtime_data.client
 | 
			
		||||
    return XboxSource(hass, client)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,12 +7,10 @@ from collections.abc import Iterable
 | 
			
		||||
import re
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
from xbox.webapi.api.client import XboxLiveClient
 | 
			
		||||
from xbox.webapi.api.provider.smartglass.models import (
 | 
			
		||||
    InputKeyType,
 | 
			
		||||
    PowerState,
 | 
			
		||||
    SmartglassConsole,
 | 
			
		||||
    SmartglassConsoleList,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.remote import (
 | 
			
		||||
@@ -21,30 +19,25 @@ from homeassistant.components.remote import (
 | 
			
		||||
    DEFAULT_DELAY_SECS,
 | 
			
		||||
    RemoteEntity,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.device_registry import DeviceInfo
 | 
			
		||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
from .coordinator import ConsoleData, XboxUpdateCoordinator
 | 
			
		||||
from .coordinator import ConsoleData, XboxConfigEntry, XboxUpdateCoordinator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    entry: ConfigEntry,
 | 
			
		||||
    entry: XboxConfigEntry,
 | 
			
		||||
    async_add_entities: AddConfigEntryEntitiesCallback,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Set up Xbox media_player from a config entry."""
 | 
			
		||||
    client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"]
 | 
			
		||||
    consoles: SmartglassConsoleList = hass.data[DOMAIN][entry.entry_id]["consoles"]
 | 
			
		||||
    coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
 | 
			
		||||
        "coordinator"
 | 
			
		||||
    ]
 | 
			
		||||
    coordinator = entry.runtime_data
 | 
			
		||||
 | 
			
		||||
    async_add_entities(
 | 
			
		||||
        [XboxRemote(client, console, coordinator) for console in consoles.result]
 | 
			
		||||
        [XboxRemote(console, coordinator) for console in coordinator.consoles.result]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -53,14 +46,13 @@ class XboxRemote(CoordinatorEntity[XboxUpdateCoordinator], RemoteEntity):
 | 
			
		||||
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        client: XboxLiveClient,
 | 
			
		||||
        console: SmartglassConsole,
 | 
			
		||||
        coordinator: XboxUpdateCoordinator,
 | 
			
		||||
    ) -> None:
 | 
			
		||||
        """Initialize the Xbox Media Player."""
 | 
			
		||||
        super().__init__(coordinator)
 | 
			
		||||
        self.client: XboxLiveClient = client
 | 
			
		||||
        self._console: SmartglassConsole = console
 | 
			
		||||
        self.client = coordinator.client
 | 
			
		||||
        self._console = console
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -5,13 +5,11 @@ from __future__ import annotations
 | 
			
		||||
from functools import partial
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.sensor import SensorEntity
 | 
			
		||||
from homeassistant.config_entries import ConfigEntry
 | 
			
		||||
from homeassistant.core import HomeAssistant, callback
 | 
			
		||||
from homeassistant.helpers import entity_registry as er
 | 
			
		||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
 | 
			
		||||
 | 
			
		||||
from .const import DOMAIN
 | 
			
		||||
from .coordinator import XboxUpdateCoordinator
 | 
			
		||||
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
 | 
			
		||||
from .entity import XboxBaseEntity
 | 
			
		||||
 | 
			
		||||
SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"]
 | 
			
		||||
@@ -19,18 +17,15 @@ SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"]
 | 
			
		||||
 | 
			
		||||
async def async_setup_entry(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    config_entry: ConfigEntry,
 | 
			
		||||
    config_entry: XboxConfigEntry,
 | 
			
		||||
    async_add_entities: AddConfigEntryEntitiesCallback,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Set up Xbox Live friends."""
 | 
			
		||||
    coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][
 | 
			
		||||
        "coordinator"
 | 
			
		||||
    ]
 | 
			
		||||
    coordinator = config_entry.runtime_data
 | 
			
		||||
 | 
			
		||||
    update_friends = partial(async_update_friends, coordinator, {}, async_add_entities)
 | 
			
		||||
 | 
			
		||||
    unsub = coordinator.async_add_listener(update_friends)
 | 
			
		||||
    hass.data[DOMAIN][config_entry.entry_id]["sensor_unsub"] = unsub
 | 
			
		||||
    config_entry.async_on_unload(coordinator.async_add_listener(update_friends))
 | 
			
		||||
    update_friends()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -58,7 +58,7 @@ ACTION_PARAMETERS_CACHE: HassKey[
 | 
			
		||||
 | 
			
		||||
LLM_API_ASSIST = "assist"
 | 
			
		||||
 | 
			
		||||
BASE_PROMPT = (
 | 
			
		||||
DATE_TIME_PROMPT = (
 | 
			
		||||
    'Current time is {{ now().strftime("%H:%M:%S") }}. '
 | 
			
		||||
    'Today\'s date is {{ now().strftime("%Y-%m-%d") }}.\n'
 | 
			
		||||
)
 | 
			
		||||
@@ -592,6 +592,8 @@ class AssistAPI(API):
 | 
			
		||||
            for intent_handler in intent_handlers
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        tools.append(GetDateTimeTool())
 | 
			
		||||
 | 
			
		||||
        if exposed_entities:
 | 
			
		||||
            if exposed_entities[CALENDAR_DOMAIN]:
 | 
			
		||||
                names = []
 | 
			
		||||
@@ -1181,3 +1183,29 @@ class GetLiveContextTool(Tool):
 | 
			
		||||
            "success": True,
 | 
			
		||||
            "result": "\n".join(prompt),
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GetDateTimeTool(Tool):
 | 
			
		||||
    """Tool for getting the current date and time."""
 | 
			
		||||
 | 
			
		||||
    name = "GetDateTime"
 | 
			
		||||
    description = "Provides the current date and time."
 | 
			
		||||
 | 
			
		||||
    async def async_call(
 | 
			
		||||
        self,
 | 
			
		||||
        hass: HomeAssistant,
 | 
			
		||||
        tool_input: ToolInput,
 | 
			
		||||
        llm_context: LLMContext,
 | 
			
		||||
    ) -> JsonObjectType:
 | 
			
		||||
        """Get the current date and time."""
 | 
			
		||||
        now = dt_util.now()
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            "success": True,
 | 
			
		||||
            "result": {
 | 
			
		||||
                "date": now.strftime("%Y-%m-%d"),
 | 
			
		||||
                "time": now.strftime("%H:%M:%S"),
 | 
			
		||||
                "timezone": now.strftime("%Z"),
 | 
			
		||||
                "weekday": now.strftime("%A"),
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ mutagen==1.47.0
 | 
			
		||||
orjson==3.11.3
 | 
			
		||||
packaging>=23.1
 | 
			
		||||
paho-mqtt==2.1.0
 | 
			
		||||
Pillow==11.3.0
 | 
			
		||||
Pillow==12.0.0
 | 
			
		||||
propcache==0.4.1
 | 
			
		||||
psutil-home-assistant==0.0.1
 | 
			
		||||
PyJWT==2.10.1
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,7 @@ from homeassistant.helpers.check_config import async_check_ha_config_file
 | 
			
		||||
 | 
			
		||||
# mypy: allow-untyped-calls, allow-untyped-defs
 | 
			
		||||
 | 
			
		||||
REQUIREMENTS = ("colorlog==6.9.0",)
 | 
			
		||||
REQUIREMENTS = ("colorlog==6.10.1",)
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
MOCKS: dict[str, tuple[str, Callable]] = {
 | 
			
		||||
 
 | 
			
		||||
@@ -126,24 +126,13 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
_IGNORE_ROOT_IMPORT = (
 | 
			
		||||
    "automation",
 | 
			
		||||
    "bluetooth",
 | 
			
		||||
    "camera",
 | 
			
		||||
    "cast",
 | 
			
		||||
    "device_automation",
 | 
			
		||||
    "device_tracker",
 | 
			
		||||
    "ffmpeg",
 | 
			
		||||
    "ffmpeg_motion",
 | 
			
		||||
    "google_assistant",
 | 
			
		||||
    "homeassistant",
 | 
			
		||||
    "homeassistant_hardware",
 | 
			
		||||
    "http",
 | 
			
		||||
    "manual",
 | 
			
		||||
    "plex",
 | 
			
		||||
    "recorder",
 | 
			
		||||
    "rest",
 | 
			
		||||
    "script",
 | 
			
		||||
    "stream",
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@ dependencies = [
 | 
			
		||||
  "PyJWT==2.10.1",
 | 
			
		||||
  # PyJWT has loose dependency. We want the latest one.
 | 
			
		||||
  "cryptography==46.0.2",
 | 
			
		||||
  "Pillow==11.3.0",
 | 
			
		||||
  "Pillow==12.0.0",
 | 
			
		||||
  "propcache==0.4.1",
 | 
			
		||||
  "pyOpenSSL==25.3.0",
 | 
			
		||||
  "orjson==3.11.3",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								requirements.txt
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								requirements.txt
									
									
									
										generated
									
									
									
								
							@@ -30,7 +30,7 @@ Jinja2==3.1.6
 | 
			
		||||
lru-dict==1.3.0
 | 
			
		||||
PyJWT==2.10.1
 | 
			
		||||
cryptography==46.0.2
 | 
			
		||||
Pillow==11.3.0
 | 
			
		||||
Pillow==12.0.0
 | 
			
		||||
propcache==0.4.1
 | 
			
		||||
pyOpenSSL==25.3.0
 | 
			
		||||
orjson==3.11.3
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								requirements_all.txt
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								requirements_all.txt
									
									
									
										generated
									
									
									
								
							@@ -36,7 +36,7 @@ PSNAWP==3.0.0
 | 
			
		||||
# homeassistant.components.seven_segments
 | 
			
		||||
# homeassistant.components.sighthound
 | 
			
		||||
# homeassistant.components.tensorflow
 | 
			
		||||
Pillow==11.3.0
 | 
			
		||||
Pillow==12.0.0
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.plex
 | 
			
		||||
PlexAPI==4.15.16
 | 
			
		||||
@@ -319,7 +319,7 @@ aiolookin==1.0.0
 | 
			
		||||
aiolyric==2.0.2
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.mealie
 | 
			
		||||
aiomealie==1.0.0
 | 
			
		||||
aiomealie==1.0.1
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.modern_forms
 | 
			
		||||
aiomodernforms==0.1.8
 | 
			
		||||
@@ -739,7 +739,7 @@ clx-sdk-xms==1.0.0
 | 
			
		||||
coinbase-advanced-py==1.2.2
 | 
			
		||||
 | 
			
		||||
# homeassistant.scripts.check_config
 | 
			
		||||
colorlog==6.9.0
 | 
			
		||||
colorlog==6.10.1
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.color_extractor
 | 
			
		||||
colorthief==0.2.1
 | 
			
		||||
@@ -1148,7 +1148,7 @@ ha-philipsjs==3.2.4
 | 
			
		||||
ha-silabs-firmware-client==0.2.0
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.habitica
 | 
			
		||||
habiticalib==0.4.5
 | 
			
		||||
habiticalib==0.4.6
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.bluetooth
 | 
			
		||||
habluetooth==5.7.0
 | 
			
		||||
@@ -1732,7 +1732,7 @@ plexauth==0.0.6
 | 
			
		||||
plexwebsocket==0.0.14
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.plugwise
 | 
			
		||||
plugwise==1.8.0
 | 
			
		||||
plugwise==1.8.1
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.serial_pm
 | 
			
		||||
pmsensor==0.4
 | 
			
		||||
@@ -2629,7 +2629,7 @@ pyvera==0.3.16
 | 
			
		||||
pyversasense==0.0.6
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.vesync
 | 
			
		||||
pyvesync==3.1.0
 | 
			
		||||
pyvesync==3.1.2
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.vizio
 | 
			
		||||
pyvizio==0.1.61
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12
									
								
								requirements_test_all.txt
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								requirements_test_all.txt
									
									
									
										generated
									
									
									
								
							@@ -36,7 +36,7 @@ PSNAWP==3.0.0
 | 
			
		||||
# homeassistant.components.seven_segments
 | 
			
		||||
# homeassistant.components.sighthound
 | 
			
		||||
# homeassistant.components.tensorflow
 | 
			
		||||
Pillow==11.3.0
 | 
			
		||||
Pillow==12.0.0
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.plex
 | 
			
		||||
PlexAPI==4.15.16
 | 
			
		||||
@@ -301,7 +301,7 @@ aiolookin==1.0.0
 | 
			
		||||
aiolyric==2.0.2
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.mealie
 | 
			
		||||
aiomealie==1.0.0
 | 
			
		||||
aiomealie==1.0.1
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.modern_forms
 | 
			
		||||
aiomodernforms==0.1.8
 | 
			
		||||
@@ -648,7 +648,7 @@ caldav==1.6.0
 | 
			
		||||
coinbase-advanced-py==1.2.2
 | 
			
		||||
 | 
			
		||||
# homeassistant.scripts.check_config
 | 
			
		||||
colorlog==6.9.0
 | 
			
		||||
colorlog==6.10.1
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.color_extractor
 | 
			
		||||
colorthief==0.2.1
 | 
			
		||||
@@ -1009,7 +1009,7 @@ ha-philipsjs==3.2.4
 | 
			
		||||
ha-silabs-firmware-client==0.2.0
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.habitica
 | 
			
		||||
habiticalib==0.4.5
 | 
			
		||||
habiticalib==0.4.6
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.bluetooth
 | 
			
		||||
habluetooth==5.7.0
 | 
			
		||||
@@ -1473,7 +1473,7 @@ plexauth==0.0.6
 | 
			
		||||
plexwebsocket==0.0.14
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.plugwise
 | 
			
		||||
plugwise==1.8.0
 | 
			
		||||
plugwise==1.8.1
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.poolsense
 | 
			
		||||
poolsense==0.0.8
 | 
			
		||||
@@ -2187,7 +2187,7 @@ pyuptimerobot==22.2.0
 | 
			
		||||
pyvera==0.3.16
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.vesync
 | 
			
		||||
pyvesync==3.1.0
 | 
			
		||||
pyvesync==3.1.2
 | 
			
		||||
 | 
			
		||||
# homeassistant.components.vizio
 | 
			
		||||
pyvizio==0.1.61
 | 
			
		||||
 
 | 
			
		||||
@@ -1755,7 +1755,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
 | 
			
		||||
    "omnilogic",
 | 
			
		||||
    "oncue",
 | 
			
		||||
    "ondilo_ico",
 | 
			
		||||
    "onewire",
 | 
			
		||||
    "onvif",
 | 
			
		||||
    "open_meteo",
 | 
			
		||||
    "openai_conversation",
 | 
			
		||||
 
 | 
			
		||||
@@ -454,7 +454,10 @@ async def test_function_call(
 | 
			
		||||
            agent_id=agent_id,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    assert "Today's date is 2024-06-03." in mock_create.mock_calls[1][2]["system"]
 | 
			
		||||
    assert (
 | 
			
		||||
        "You are a voice assistant for Home Assistant."
 | 
			
		||||
        in mock_create.mock_calls[1][2]["system"]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
 | 
			
		||||
    assert (
 | 
			
		||||
 
 | 
			
		||||
@@ -33,7 +33,9 @@ from homeassistant.components.assist_pipeline.pipeline import (  # pylint: disab
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN
 | 
			
		||||
from homeassistant.components.cloud.http_api import validate_language_voice
 | 
			
		||||
from homeassistant.components.google_assistant.helpers import GoogleEntity
 | 
			
		||||
from homeassistant.components.google_assistant.helpers import (  # pylint: disable=hass-component-root-import
 | 
			
		||||
    GoogleEntity,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.homeassistant import exposed_entities
 | 
			
		||||
from homeassistant.components.websocket_api import ERR_INVALID_FORMAT
 | 
			
		||||
from homeassistant.core import HomeAssistant, State
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ from tests.common import assert_setup_component
 | 
			
		||||
 | 
			
		||||
COMPONENT = "comfoconnect"
 | 
			
		||||
VALID_CONFIG = {
 | 
			
		||||
    COMPONENT: {"host": "1.2.3.4"},
 | 
			
		||||
    COMPONENT: {"host": "192.0.2.1"},
 | 
			
		||||
    SENSOR_DOMAIN: {
 | 
			
		||||
        "platform": COMPONENT,
 | 
			
		||||
        "resources": [
 | 
			
		||||
@@ -31,7 +31,10 @@ VALID_CONFIG = {
 | 
			
		||||
def mock_bridge_discover() -> Generator[MagicMock]:
 | 
			
		||||
    """Mock the bridge discover method."""
 | 
			
		||||
    with patch("pycomfoconnect.bridge.Bridge.discover") as mock_bridge_discover:
 | 
			
		||||
        mock_bridge_discover.return_value[0].uuid.hex.return_value = "00"
 | 
			
		||||
        bridge = MagicMock()
 | 
			
		||||
        bridge.uuid.hex.return_value = "00"
 | 
			
		||||
        bridge.host = "192.0.2.1"
 | 
			
		||||
        mock_bridge_discover.return_value = [bridge]
 | 
			
		||||
        yield mock_bridge_discover
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -44,11 +47,28 @@ def mock_comfoconnect_command() -> Generator[MagicMock]:
 | 
			
		||||
        yield mock_comfoconnect_command
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
def mock_comfoconnect_connect() -> Generator[MagicMock]:
 | 
			
		||||
    """Mock the ComfoConnect connect method."""
 | 
			
		||||
    with patch("pycomfoconnect.comfoconnect.ComfoConnect.connect") as mock_connect:
 | 
			
		||||
        yield mock_connect
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture(autouse=True)
 | 
			
		||||
def mock_comfoconnect_disconnect() -> Generator[MagicMock]:
 | 
			
		||||
    """Mock the ComfoConnect disconnect method, autouse=True to mock in teardown."""
 | 
			
		||||
    with patch(
 | 
			
		||||
        "pycomfoconnect.comfoconnect.ComfoConnect.disconnect"
 | 
			
		||||
    ) as mock_disconnect:
 | 
			
		||||
        yield mock_disconnect
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
async def setup_sensor(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_bridge_discover: MagicMock,
 | 
			
		||||
    mock_comfoconnect_command: MagicMock,
 | 
			
		||||
    mock_comfoconnect_connect: MagicMock,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Set up demo sensor component."""
 | 
			
		||||
    with assert_setup_component(1, SENSOR_DOMAIN):
 | 
			
		||||
 
 | 
			
		||||
@@ -156,6 +156,106 @@ async def test_multiple_llm_apis(
 | 
			
		||||
    assert chat_log.llm_api.api.id == "assist|my-api"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_dynamic_time_injection(
 | 
			
		||||
    hass: HomeAssistant, mock_conversation_input: ConversationInput
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that dynamic time injection works correctly."""
 | 
			
		||||
 | 
			
		||||
    class MyAPI(llm.API):
 | 
			
		||||
        """Test API."""
 | 
			
		||||
 | 
			
		||||
        async def async_get_api_instance(
 | 
			
		||||
            self, llm_context: llm.LLMContext
 | 
			
		||||
        ) -> llm.APIInstance:
 | 
			
		||||
            """Return a list of tools."""
 | 
			
		||||
            return llm.APIInstance(self, "My API Prompt", llm_context, [])
 | 
			
		||||
 | 
			
		||||
    not_assist_1_api = MyAPI(hass=hass, id="not-assist-1", name="Not Assist 1")
 | 
			
		||||
    llm.async_register_api(hass, not_assist_1_api)
 | 
			
		||||
 | 
			
		||||
    not_assist_2_api = MyAPI(hass=hass, id="not-assist-2", name="Not Assist 2")
 | 
			
		||||
    llm.async_register_api(hass, not_assist_2_api)
 | 
			
		||||
 | 
			
		||||
    # Helper to track which prompts are rendered
 | 
			
		||||
    rendered_prompts = []
 | 
			
		||||
 | 
			
		||||
    async def fake_expand_prompt_template(
 | 
			
		||||
        llm_context, prompt, language, user_name=None
 | 
			
		||||
    ):
 | 
			
		||||
        rendered_prompts.append(prompt)
 | 
			
		||||
        return prompt
 | 
			
		||||
 | 
			
		||||
    # Case 1: No API used -> prompt should contain the time
 | 
			
		||||
    with (
 | 
			
		||||
        chat_session.async_get_chat_session(hass) as session,
 | 
			
		||||
        async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
 | 
			
		||||
    ):
 | 
			
		||||
        chat_log._async_expand_prompt_template = fake_expand_prompt_template
 | 
			
		||||
        rendered_prompts.clear()
 | 
			
		||||
        await chat_log.async_provide_llm_data(
 | 
			
		||||
            mock_conversation_input.as_llm_context("test"),
 | 
			
		||||
            user_llm_hass_api=None,
 | 
			
		||||
            user_llm_prompt=None,
 | 
			
		||||
        )
 | 
			
		||||
        assert llm.DATE_TIME_PROMPT in rendered_prompts
 | 
			
		||||
 | 
			
		||||
    # Case 2: Single API (not assist) -> prompt should contain the time
 | 
			
		||||
    with (
 | 
			
		||||
        chat_session.async_get_chat_session(hass) as session,
 | 
			
		||||
        async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
 | 
			
		||||
    ):
 | 
			
		||||
        chat_log._async_expand_prompt_template = fake_expand_prompt_template
 | 
			
		||||
        rendered_prompts.clear()
 | 
			
		||||
        await chat_log.async_provide_llm_data(
 | 
			
		||||
            mock_conversation_input.as_llm_context("test"),
 | 
			
		||||
            user_llm_hass_api=["not-assist-1"],
 | 
			
		||||
            user_llm_prompt=None,
 | 
			
		||||
        )
 | 
			
		||||
        assert llm.DATE_TIME_PROMPT in rendered_prompts
 | 
			
		||||
 | 
			
		||||
    # Case 3: Single API (assist) -> prompt should NOT contain the time
 | 
			
		||||
    with (
 | 
			
		||||
        chat_session.async_get_chat_session(hass) as session,
 | 
			
		||||
        async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
 | 
			
		||||
    ):
 | 
			
		||||
        chat_log._async_expand_prompt_template = fake_expand_prompt_template
 | 
			
		||||
        rendered_prompts.clear()
 | 
			
		||||
        await chat_log.async_provide_llm_data(
 | 
			
		||||
            mock_conversation_input.as_llm_context("test"),
 | 
			
		||||
            user_llm_hass_api=[llm.LLM_API_ASSIST],
 | 
			
		||||
            user_llm_prompt=None,
 | 
			
		||||
        )
 | 
			
		||||
        assert llm.DATE_TIME_PROMPT not in rendered_prompts
 | 
			
		||||
 | 
			
		||||
    # Case 4: Merged API (without assist) -> prompt should contain the time
 | 
			
		||||
    with (
 | 
			
		||||
        chat_session.async_get_chat_session(hass) as session,
 | 
			
		||||
        async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
 | 
			
		||||
    ):
 | 
			
		||||
        chat_log._async_expand_prompt_template = fake_expand_prompt_template
 | 
			
		||||
        rendered_prompts.clear()
 | 
			
		||||
        await chat_log.async_provide_llm_data(
 | 
			
		||||
            mock_conversation_input.as_llm_context("test"),
 | 
			
		||||
            user_llm_hass_api=["not-assist-1", "not-assist-2"],
 | 
			
		||||
            user_llm_prompt=None,
 | 
			
		||||
        )
 | 
			
		||||
        assert llm.DATE_TIME_PROMPT in rendered_prompts
 | 
			
		||||
 | 
			
		||||
    # Case 5: Merged API (with assist) -> prompt should NOT contain the time
 | 
			
		||||
    with (
 | 
			
		||||
        chat_session.async_get_chat_session(hass) as session,
 | 
			
		||||
        async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
 | 
			
		||||
    ):
 | 
			
		||||
        chat_log._async_expand_prompt_template = fake_expand_prompt_template
 | 
			
		||||
        rendered_prompts.clear()
 | 
			
		||||
        await chat_log.async_provide_llm_data(
 | 
			
		||||
            mock_conversation_input.as_llm_context("test"),
 | 
			
		||||
            user_llm_hass_api=[llm.LLM_API_ASSIST, "not-assist-1"],
 | 
			
		||||
            user_llm_prompt=None,
 | 
			
		||||
        )
 | 
			
		||||
        assert llm.DATE_TIME_PROMPT not in rendered_prompts
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_template_error(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_conversation_input: ConversationInput,
 | 
			
		||||
 
 | 
			
		||||
@@ -154,12 +154,17 @@ async def test_set_cover_position(hass: HomeAssistant) -> None:
 | 
			
		||||
    """Test moving the cover to a specific position."""
 | 
			
		||||
    state = hass.states.get(ENTITY_COVER)
 | 
			
		||||
    assert state.attributes[ATTR_CURRENT_POSITION] == 70
 | 
			
		||||
 | 
			
		||||
    # close to 10%
 | 
			
		||||
    await hass.services.async_call(
 | 
			
		||||
        COVER_DOMAIN,
 | 
			
		||||
        SERVICE_SET_COVER_POSITION,
 | 
			
		||||
        {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 10},
 | 
			
		||||
        blocking=True,
 | 
			
		||||
    )
 | 
			
		||||
    state = hass.states.get(ENTITY_COVER)
 | 
			
		||||
    assert state.state == CoverState.CLOSING
 | 
			
		||||
 | 
			
		||||
    for _ in range(6):
 | 
			
		||||
        future = dt_util.utcnow() + timedelta(seconds=1)
 | 
			
		||||
        async_fire_time_changed(hass, future)
 | 
			
		||||
@@ -167,6 +172,26 @@ async def test_set_cover_position(hass: HomeAssistant) -> None:
 | 
			
		||||
 | 
			
		||||
    state = hass.states.get(ENTITY_COVER)
 | 
			
		||||
    assert state.attributes[ATTR_CURRENT_POSITION] == 10
 | 
			
		||||
    assert state.state == CoverState.OPEN
 | 
			
		||||
 | 
			
		||||
    # open to 80%
 | 
			
		||||
    await hass.services.async_call(
 | 
			
		||||
        COVER_DOMAIN,
 | 
			
		||||
        SERVICE_SET_COVER_POSITION,
 | 
			
		||||
        {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 80},
 | 
			
		||||
        blocking=True,
 | 
			
		||||
    )
 | 
			
		||||
    state = hass.states.get(ENTITY_COVER)
 | 
			
		||||
    assert state.state == CoverState.OPENING
 | 
			
		||||
 | 
			
		||||
    for _ in range(7):
 | 
			
		||||
        future = dt_util.utcnow() + timedelta(seconds=1)
 | 
			
		||||
        async_fire_time_changed(hass, future)
 | 
			
		||||
        await hass.async_block_till_done()
 | 
			
		||||
 | 
			
		||||
    state = hass.states.get(ENTITY_COVER)
 | 
			
		||||
    assert state.attributes[ATTR_CURRENT_POSITION] == 80
 | 
			
		||||
    assert state.state == CoverState.OPEN
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_stop_cover(hass: HomeAssistant) -> None:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,56 +0,0 @@
 | 
			
		||||
"""Fixtures for energy component tests."""
 | 
			
		||||
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.energy import async_get_manager
 | 
			
		||||
from homeassistant.components.energy.data import EnergyManager
 | 
			
		||||
from homeassistant.components.recorder import Recorder
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.setup import async_setup_component
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
def mock_is_entity_recorded():
 | 
			
		||||
    """Mock recorder.is_entity_recorded."""
 | 
			
		||||
    mocks = {}
 | 
			
		||||
 | 
			
		||||
    with patch(
 | 
			
		||||
        "homeassistant.components.recorder.is_entity_recorded",
 | 
			
		||||
        side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
 | 
			
		||||
    ):
 | 
			
		||||
        yield mocks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
def mock_get_metadata():
 | 
			
		||||
    """Mock recorder.statistics.get_metadata."""
 | 
			
		||||
    mocks = {}
 | 
			
		||||
 | 
			
		||||
    def _get_metadata(_hass, *, statistic_ids):
 | 
			
		||||
        result = {}
 | 
			
		||||
        for statistic_id in statistic_ids:
 | 
			
		||||
            if statistic_id in mocks:
 | 
			
		||||
                if mocks[statistic_id] is not None:
 | 
			
		||||
                    result[statistic_id] = mocks[statistic_id]
 | 
			
		||||
            else:
 | 
			
		||||
                result[statistic_id] = (1, {})
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
    with patch(
 | 
			
		||||
        "homeassistant.components.recorder.statistics.get_metadata",
 | 
			
		||||
        wraps=_get_metadata,
 | 
			
		||||
    ):
 | 
			
		||||
        yield mocks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
async def mock_energy_manager(
 | 
			
		||||
    recorder_mock: Recorder, hass: HomeAssistant
 | 
			
		||||
) -> EnergyManager:
 | 
			
		||||
    """Set up energy."""
 | 
			
		||||
    assert await async_setup_component(hass, "energy", {"energy": {}})
 | 
			
		||||
    manager = await async_get_manager(hass)
 | 
			
		||||
    manager.data = manager.default_preferences()
 | 
			
		||||
    return manager
 | 
			
		||||
@@ -1,24 +1,65 @@
 | 
			
		||||
"""Test that validation works."""
 | 
			
		||||
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.energy import validate
 | 
			
		||||
from homeassistant.components.energy import async_get_manager, validate
 | 
			
		||||
from homeassistant.components.energy.data import EnergyManager
 | 
			
		||||
from homeassistant.components.recorder import Recorder
 | 
			
		||||
from homeassistant.const import UnitOfEnergy
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers.json import JSON_DUMP
 | 
			
		||||
from homeassistant.setup import async_setup_component
 | 
			
		||||
 | 
			
		||||
ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy))
 | 
			
		||||
 | 
			
		||||
ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
def mock_is_entity_recorded():
 | 
			
		||||
    """Mock recorder.is_entity_recorded."""
 | 
			
		||||
    mocks = {}
 | 
			
		||||
 | 
			
		||||
    with patch(
 | 
			
		||||
        "homeassistant.components.recorder.is_entity_recorded",
 | 
			
		||||
        side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
 | 
			
		||||
    ):
 | 
			
		||||
        yield mocks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture
 | 
			
		||||
def mock_get_metadata():
 | 
			
		||||
    """Mock recorder.statistics.get_metadata."""
 | 
			
		||||
    mocks = {}
 | 
			
		||||
 | 
			
		||||
    def _get_metadata(_hass, *, statistic_ids):
 | 
			
		||||
        result = {}
 | 
			
		||||
        for statistic_id in statistic_ids:
 | 
			
		||||
            if statistic_id in mocks:
 | 
			
		||||
                if mocks[statistic_id] is not None:
 | 
			
		||||
                    result[statistic_id] = mocks[statistic_id]
 | 
			
		||||
            else:
 | 
			
		||||
                result[statistic_id] = (1, {})
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
    with patch(
 | 
			
		||||
        "homeassistant.components.recorder.statistics.get_metadata",
 | 
			
		||||
        wraps=_get_metadata,
 | 
			
		||||
    ):
 | 
			
		||||
        yield mocks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture(autouse=True)
 | 
			
		||||
async def setup_energy_for_validation(
 | 
			
		||||
    mock_energy_manager: EnergyManager,
 | 
			
		||||
async def mock_energy_manager(
 | 
			
		||||
    recorder_mock: Recorder, hass: HomeAssistant
 | 
			
		||||
) -> EnergyManager:
 | 
			
		||||
    """Ensure energy manager is set up for validation tests."""
 | 
			
		||||
    return mock_energy_manager
 | 
			
		||||
    """Set up energy."""
 | 
			
		||||
    assert await async_setup_component(hass, "energy", {"energy": {}})
 | 
			
		||||
    manager = await async_get_manager(hass)
 | 
			
		||||
    manager.data = manager.default_preferences()
 | 
			
		||||
    return manager
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_empty_config(hass: HomeAssistant) -> None:
 | 
			
		||||
@@ -372,7 +413,6 @@ async def test_validation_grid(
 | 
			
		||||
                            "stat_compensation": "sensor.grid_compensation_1",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "power": [],
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
@@ -464,7 +504,6 @@ async def test_validation_grid_external_cost_compensation(
 | 
			
		||||
                            "stat_compensation": "external:grid_compensation_1",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "power": [],
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
@@ -703,7 +742,6 @@ async def test_validation_grid_price_errors(
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [],
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
@@ -909,7 +947,6 @@ async def test_validation_grid_no_costs_tracking(
 | 
			
		||||
                            "number_energy_price": None,
 | 
			
		||||
                        },
 | 
			
		||||
                    ],
 | 
			
		||||
                    "power": [],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,450 +0,0 @@
 | 
			
		||||
"""Test power stat validation."""
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.energy import validate
 | 
			
		||||
from homeassistant.components.energy.data import EnergyManager
 | 
			
		||||
from homeassistant.const import UnitOfPower
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
 | 
			
		||||
POWER_UNITS_STRING = ", ".join(tuple(UnitOfPower))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.fixture(autouse=True)
 | 
			
		||||
async def setup_energy_for_validation(
 | 
			
		||||
    mock_energy_manager: EnergyManager,
 | 
			
		||||
) -> EnergyManager:
 | 
			
		||||
    """Ensure energy manager is set up for validation tests."""
 | 
			
		||||
    return mock_energy_manager
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_valid(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with valid power sensor."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.grid_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set(
 | 
			
		||||
        "sensor.grid_power",
 | 
			
		||||
        "1.5",
 | 
			
		||||
        {
 | 
			
		||||
            "device_class": "power",
 | 
			
		||||
            "unit_of_measurement": UnitOfPower.KILO_WATT,
 | 
			
		||||
            "state_class": "measurement",
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [[]],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_wrong_unit(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with power sensor having wrong unit."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.grid_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set(
 | 
			
		||||
        "sensor.grid_power",
 | 
			
		||||
        "1.5",
 | 
			
		||||
        {
 | 
			
		||||
            "device_class": "power",
 | 
			
		||||
            "unit_of_measurement": "beers",
 | 
			
		||||
            "state_class": "measurement",
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "entity_unexpected_unit_power",
 | 
			
		||||
                    "affected_entities": {("sensor.grid_power", "beers")},
 | 
			
		||||
                    "translation_placeholders": {"power_units": POWER_UNITS_STRING},
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_wrong_state_class(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with power sensor having wrong state class."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.grid_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set(
 | 
			
		||||
        "sensor.grid_power",
 | 
			
		||||
        "1.5",
 | 
			
		||||
        {
 | 
			
		||||
            "device_class": "power",
 | 
			
		||||
            "unit_of_measurement": UnitOfPower.KILO_WATT,
 | 
			
		||||
            "state_class": "total_increasing",
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "entity_unexpected_state_class",
 | 
			
		||||
                    "affected_entities": {("sensor.grid_power", "total_increasing")},
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_entity_missing(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with missing power sensor."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.missing_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "statistics_not_defined",
 | 
			
		||||
                    "affected_entities": {("sensor.missing_power", None)},
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                },
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "entity_not_defined",
 | 
			
		||||
                    "affected_entities": {("sensor.missing_power", None)},
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                },
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_entity_unavailable(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with unavailable power sensor."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.unavailable_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set("sensor.unavailable_power", "unavailable", {})
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "entity_unavailable",
 | 
			
		||||
                    "affected_entities": {("sensor.unavailable_power", "unavailable")},
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_entity_non_numeric(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with non-numeric power sensor."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.non_numeric_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set(
 | 
			
		||||
        "sensor.non_numeric_power",
 | 
			
		||||
        "not_a_number",
 | 
			
		||||
        {
 | 
			
		||||
            "device_class": "power",
 | 
			
		||||
            "unit_of_measurement": UnitOfPower.KILO_WATT,
 | 
			
		||||
            "state_class": "measurement",
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "entity_state_non_numeric",
 | 
			
		||||
                    "affected_entities": {("sensor.non_numeric_power", "not_a_number")},
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_wrong_device_class(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with power sensor having wrong device class."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.wrong_device_class_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set(
 | 
			
		||||
        "sensor.wrong_device_class_power",
 | 
			
		||||
        "1.5",
 | 
			
		||||
        {
 | 
			
		||||
            "device_class": "energy",
 | 
			
		||||
            "unit_of_measurement": "kWh",
 | 
			
		||||
            "state_class": "measurement",
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "entity_unexpected_device_class",
 | 
			
		||||
                    "affected_entities": {
 | 
			
		||||
                        ("sensor.wrong_device_class_power", "energy")
 | 
			
		||||
                    },
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_different_units(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with power sensors using different valid units."""
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.power_watt",
 | 
			
		||||
                        },
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.power_milliwatt",
 | 
			
		||||
                        },
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set(
 | 
			
		||||
        "sensor.power_watt",
 | 
			
		||||
        "1500",
 | 
			
		||||
        {
 | 
			
		||||
            "device_class": "power",
 | 
			
		||||
            "unit_of_measurement": UnitOfPower.WATT,
 | 
			
		||||
            "state_class": "measurement",
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
    hass.states.async_set(
 | 
			
		||||
        "sensor.power_milliwatt",
 | 
			
		||||
        "1500000",
 | 
			
		||||
        {
 | 
			
		||||
            "device_class": "power",
 | 
			
		||||
            "unit_of_measurement": UnitOfPower.MILLIWATT,
 | 
			
		||||
            "state_class": "measurement",
 | 
			
		||||
        },
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [[]],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_external_statistics(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with external power statistics (non-entity)."""
 | 
			
		||||
    mock_get_metadata["external:power_stat"] = None
 | 
			
		||||
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "external:power_stat",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "statistics_not_defined",
 | 
			
		||||
                    "affected_entities": {("external:power_stat", None)},
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_validation_grid_power_recorder_untracked(
 | 
			
		||||
    hass: HomeAssistant, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test validating grid with power sensor not tracked by recorder."""
 | 
			
		||||
    mock_is_entity_recorded["sensor.untracked_power"] = False
 | 
			
		||||
 | 
			
		||||
    await mock_energy_manager.async_update(
 | 
			
		||||
        {
 | 
			
		||||
            "energy_sources": [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "grid",
 | 
			
		||||
                    "flow_from": [],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [
 | 
			
		||||
                        {
 | 
			
		||||
                            "stat_power": "sensor.untracked_power",
 | 
			
		||||
                        }
 | 
			
		||||
                    ],
 | 
			
		||||
                    "cost_adjustment_day": 0.0,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = await validate.async_validate(hass)
 | 
			
		||||
    assert result.as_dict() == {
 | 
			
		||||
        "energy_sources": [
 | 
			
		||||
            [
 | 
			
		||||
                {
 | 
			
		||||
                    "type": "recorder_untracked",
 | 
			
		||||
                    "affected_entities": {("sensor.untracked_power", None)},
 | 
			
		||||
                    "translation_placeholders": None,
 | 
			
		||||
                }
 | 
			
		||||
            ]
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [],
 | 
			
		||||
    }
 | 
			
		||||
@@ -137,24 +137,17 @@ async def test_save_preferences(
 | 
			
		||||
                        "number_energy_price": 0.20,
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
                "power": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "stat_power": "sensor.grid_power",
 | 
			
		||||
                    }
 | 
			
		||||
                ],
 | 
			
		||||
                "cost_adjustment_day": 1.2,
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "type": "solar",
 | 
			
		||||
                "stat_energy_from": "my_solar_production",
 | 
			
		||||
                "stat_power": "my_solar_power",
 | 
			
		||||
                "config_entry_solar_forecast": ["predicted_config_entry"],
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                "type": "battery",
 | 
			
		||||
                "stat_energy_from": "my_battery_draining",
 | 
			
		||||
                "stat_energy_to": "my_battery_charging",
 | 
			
		||||
                "stat_power": "my_battery_power",
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        "device_consumption": [
 | 
			
		||||
@@ -162,7 +155,6 @@ async def test_save_preferences(
 | 
			
		||||
                "stat_consumption": "some_device_usage",
 | 
			
		||||
                "name": "My Device",
 | 
			
		||||
                "included_in_stat": "sensor.some_other_device",
 | 
			
		||||
                "stat_power": "sensor.some_device_power",
 | 
			
		||||
            }
 | 
			
		||||
        ],
 | 
			
		||||
    }
 | 
			
		||||
@@ -261,7 +253,6 @@ async def test_handle_duplicate_from_stat(
 | 
			
		||||
                        },
 | 
			
		||||
                    ],
 | 
			
		||||
                    "flow_to": [],
 | 
			
		||||
                    "power": [],
 | 
			
		||||
                    "cost_adjustment_day": 0,
 | 
			
		||||
                },
 | 
			
		||||
            ],
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,9 @@ import pytest
 | 
			
		||||
from webrtc_models import RTCIceCandidateInit
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.camera import (
 | 
			
		||||
    DATA_CAMERA_PREFS,
 | 
			
		||||
    CameraPreferences,
 | 
			
		||||
    DynamicStreamSettings,
 | 
			
		||||
    StreamType,
 | 
			
		||||
    WebRTCAnswer as HAWebRTCAnswer,
 | 
			
		||||
    WebRTCCandidate as HAWebRTCCandidate,
 | 
			
		||||
@@ -29,11 +32,6 @@ from homeassistant.components.camera import (
 | 
			
		||||
    WebRTCSendMessage,
 | 
			
		||||
    async_get_image,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.camera.const import DATA_CAMERA_PREFS
 | 
			
		||||
from homeassistant.components.camera.prefs import (
 | 
			
		||||
    CameraPreferences,
 | 
			
		||||
    DynamicStreamSettings,
 | 
			
		||||
)
 | 
			
		||||
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
 | 
			
		||||
from homeassistant.components.go2rtc import HomeAssistant, WebRTCProvider
 | 
			
		||||
from homeassistant.components.go2rtc.const import (
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +1,12 @@
 | 
			
		||||
"""Tests for the growatt_server component."""
 | 
			
		||||
"""Tests for the Growatt Server integration."""
 | 
			
		||||
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
 | 
			
		||||
from tests.common import MockConfigEntry
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
 | 
			
		||||
    """Set up the Growatt Server integration for testing."""
 | 
			
		||||
    config_entry.add_to_hass(hass)
 | 
			
		||||
    await hass.config_entries.async_setup(config_entry.entry_id)
 | 
			
		||||
    await hass.async_block_till_done()
 | 
			
		||||
 
 | 
			
		||||
@@ -70,8 +70,19 @@ def mock_growatt_v1_api():
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Called by MIN device coordinator during refresh
 | 
			
		||||
        # Empty dict is sufficient for switch tests (sensor tests would need real energy data)
 | 
			
		||||
        mock_v1_api.min_energy.return_value = {}
 | 
			
		||||
        # Provide realistic energy data for sensor tests
 | 
			
		||||
        mock_v1_api.min_energy.return_value = {
 | 
			
		||||
            "eChargeToday": 5.2,
 | 
			
		||||
            "eChargeTotal": 125.8,
 | 
			
		||||
            "eDischargeToday": 8.1,
 | 
			
		||||
            "eDischargeTotal": 245.6,
 | 
			
		||||
            "eSelfToday": 12.5,
 | 
			
		||||
            "eSelfTotal": 320.4,
 | 
			
		||||
            "eBatChargeToday": 6.3,
 | 
			
		||||
            "eBatChargeTotal": 150.2,
 | 
			
		||||
            "eBatDischargeToday": 7.8,
 | 
			
		||||
            "eBatDischargeTotal": 180.5,
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        # Called by total coordinator during refresh
 | 
			
		||||
        mock_v1_api.plant_energy_overview.return_value = {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										125
									
								
								tests/components/growatt_server/snapshots/test_init.ambr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								tests/components/growatt_server/snapshots/test_init.ambr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,125 @@
 | 
			
		||||
# serializer version: 1
 | 
			
		||||
# name: test_classic_api_setup
 | 
			
		||||
  DeviceRegistryEntrySnapshot({
 | 
			
		||||
    'area_id': None,
 | 
			
		||||
    'config_entries': <ANY>,
 | 
			
		||||
    'config_entries_subentries': <ANY>,
 | 
			
		||||
    'configuration_url': None,
 | 
			
		||||
    'connections': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'disabled_by': None,
 | 
			
		||||
    'entry_type': None,
 | 
			
		||||
    'hw_version': None,
 | 
			
		||||
    'id': <ANY>,
 | 
			
		||||
    'identifiers': set({
 | 
			
		||||
      tuple(
 | 
			
		||||
        'growatt_server',
 | 
			
		||||
        'TLX123456',
 | 
			
		||||
      ),
 | 
			
		||||
    }),
 | 
			
		||||
    'labels': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'manufacturer': 'Growatt',
 | 
			
		||||
    'model': None,
 | 
			
		||||
    'model_id': None,
 | 
			
		||||
    'name': 'TLX123456',
 | 
			
		||||
    'name_by_user': None,
 | 
			
		||||
    'primary_config_entry': <ANY>,
 | 
			
		||||
    'serial_number': None,
 | 
			
		||||
    'sw_version': None,
 | 
			
		||||
    'via_device_id': None,
 | 
			
		||||
  })
 | 
			
		||||
# ---
 | 
			
		||||
# name: test_device_info
 | 
			
		||||
  DeviceRegistryEntrySnapshot({
 | 
			
		||||
    'area_id': None,
 | 
			
		||||
    'config_entries': <ANY>,
 | 
			
		||||
    'config_entries_subentries': <ANY>,
 | 
			
		||||
    'configuration_url': None,
 | 
			
		||||
    'connections': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'disabled_by': None,
 | 
			
		||||
    'entry_type': None,
 | 
			
		||||
    'hw_version': None,
 | 
			
		||||
    'id': <ANY>,
 | 
			
		||||
    'identifiers': set({
 | 
			
		||||
      tuple(
 | 
			
		||||
        'growatt_server',
 | 
			
		||||
        'MIN123456',
 | 
			
		||||
      ),
 | 
			
		||||
    }),
 | 
			
		||||
    'labels': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'manufacturer': 'Growatt',
 | 
			
		||||
    'model': None,
 | 
			
		||||
    'model_id': None,
 | 
			
		||||
    'name': 'MIN123456',
 | 
			
		||||
    'name_by_user': None,
 | 
			
		||||
    'primary_config_entry': <ANY>,
 | 
			
		||||
    'serial_number': None,
 | 
			
		||||
    'sw_version': None,
 | 
			
		||||
    'via_device_id': None,
 | 
			
		||||
  })
 | 
			
		||||
# ---
 | 
			
		||||
# name: test_multiple_devices_discovered[device_min123456]
 | 
			
		||||
  DeviceRegistryEntrySnapshot({
 | 
			
		||||
    'area_id': None,
 | 
			
		||||
    'config_entries': <ANY>,
 | 
			
		||||
    'config_entries_subentries': <ANY>,
 | 
			
		||||
    'configuration_url': None,
 | 
			
		||||
    'connections': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'disabled_by': None,
 | 
			
		||||
    'entry_type': None,
 | 
			
		||||
    'hw_version': None,
 | 
			
		||||
    'id': <ANY>,
 | 
			
		||||
    'identifiers': set({
 | 
			
		||||
      tuple(
 | 
			
		||||
        'growatt_server',
 | 
			
		||||
        'MIN123456',
 | 
			
		||||
      ),
 | 
			
		||||
    }),
 | 
			
		||||
    'labels': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'manufacturer': 'Growatt',
 | 
			
		||||
    'model': None,
 | 
			
		||||
    'model_id': None,
 | 
			
		||||
    'name': 'MIN123456',
 | 
			
		||||
    'name_by_user': None,
 | 
			
		||||
    'primary_config_entry': <ANY>,
 | 
			
		||||
    'serial_number': None,
 | 
			
		||||
    'sw_version': None,
 | 
			
		||||
    'via_device_id': None,
 | 
			
		||||
  })
 | 
			
		||||
# ---
 | 
			
		||||
# name: test_multiple_devices_discovered[device_min789012]
 | 
			
		||||
  DeviceRegistryEntrySnapshot({
 | 
			
		||||
    'area_id': None,
 | 
			
		||||
    'config_entries': <ANY>,
 | 
			
		||||
    'config_entries_subentries': <ANY>,
 | 
			
		||||
    'configuration_url': None,
 | 
			
		||||
    'connections': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'disabled_by': None,
 | 
			
		||||
    'entry_type': None,
 | 
			
		||||
    'hw_version': None,
 | 
			
		||||
    'id': <ANY>,
 | 
			
		||||
    'identifiers': set({
 | 
			
		||||
      tuple(
 | 
			
		||||
        'growatt_server',
 | 
			
		||||
        'MIN789012',
 | 
			
		||||
      ),
 | 
			
		||||
    }),
 | 
			
		||||
    'labels': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'manufacturer': 'Growatt',
 | 
			
		||||
    'model': None,
 | 
			
		||||
    'model_id': None,
 | 
			
		||||
    'name': 'MIN789012',
 | 
			
		||||
    'name_by_user': None,
 | 
			
		||||
    'primary_config_entry': <ANY>,
 | 
			
		||||
    'serial_number': None,
 | 
			
		||||
    'sw_version': None,
 | 
			
		||||
    'via_device_id': None,
 | 
			
		||||
  })
 | 
			
		||||
# ---
 | 
			
		||||
							
								
								
									
										11457
									
								
								tests/components/growatt_server/snapshots/test_sensor.ambr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11457
									
								
								tests/components/growatt_server/snapshots/test_sensor.ambr
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -78,3 +78,51 @@
 | 
			
		||||
    'state': 'on',
 | 
			
		||||
  })
 | 
			
		||||
# ---
 | 
			
		||||
# name: test_switch_entity_attributes[entity_entry]
 | 
			
		||||
  EntityRegistryEntrySnapshot({
 | 
			
		||||
    'aliases': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'area_id': None,
 | 
			
		||||
    'capabilities': None,
 | 
			
		||||
    'config_entry_id': <ANY>,
 | 
			
		||||
    'config_subentry_id': <ANY>,
 | 
			
		||||
    'device_class': None,
 | 
			
		||||
    'device_id': <ANY>,
 | 
			
		||||
    'disabled_by': None,
 | 
			
		||||
    'domain': 'switch',
 | 
			
		||||
    'entity_category': <EntityCategory.CONFIG: 'config'>,
 | 
			
		||||
    'entity_id': 'switch.min123456_charge_from_grid',
 | 
			
		||||
    'has_entity_name': True,
 | 
			
		||||
    'hidden_by': None,
 | 
			
		||||
    'icon': None,
 | 
			
		||||
    'id': <ANY>,
 | 
			
		||||
    'labels': set({
 | 
			
		||||
    }),
 | 
			
		||||
    'name': None,
 | 
			
		||||
    'options': dict({
 | 
			
		||||
    }),
 | 
			
		||||
    'original_device_class': None,
 | 
			
		||||
    'original_icon': None,
 | 
			
		||||
    'original_name': 'Charge from grid',
 | 
			
		||||
    'platform': 'growatt_server',
 | 
			
		||||
    'previous_unique_id': None,
 | 
			
		||||
    'suggested_object_id': None,
 | 
			
		||||
    'supported_features': 0,
 | 
			
		||||
    'translation_key': 'ac_charge',
 | 
			
		||||
    'unique_id': 'MIN123456_ac_charge',
 | 
			
		||||
    'unit_of_measurement': None,
 | 
			
		||||
  })
 | 
			
		||||
# ---
 | 
			
		||||
# name: test_switch_entity_attributes[state]
 | 
			
		||||
  StateSnapshot({
 | 
			
		||||
    'attributes': ReadOnlyDict({
 | 
			
		||||
      'friendly_name': 'MIN123456 Charge from grid',
 | 
			
		||||
    }),
 | 
			
		||||
    'context': <ANY>,
 | 
			
		||||
    'entity_id': 'switch.min123456_charge_from_grid',
 | 
			
		||||
    'last_changed': <ANY>,
 | 
			
		||||
    'last_reported': <ANY>,
 | 
			
		||||
    'last_updated': <ANY>,
 | 
			
		||||
    'state': 'on',
 | 
			
		||||
  })
 | 
			
		||||
# ---
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										176
									
								
								tests/components/growatt_server/test_init.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								tests/components/growatt_server/test_init.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,176 @@
 | 
			
		||||
"""Tests for the Growatt Server integration."""
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
import json
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
from freezegun.api import FrozenDateTimeFactory
 | 
			
		||||
import growattServer
 | 
			
		||||
import pytest
 | 
			
		||||
from syrupy.assertion import SnapshotAssertion
 | 
			
		||||
 | 
			
		||||
from homeassistant.components.growatt_server.const import DOMAIN
 | 
			
		||||
from homeassistant.config_entries import ConfigEntryState
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers import device_registry as dr
 | 
			
		||||
 | 
			
		||||
from . import setup_integration
 | 
			
		||||
 | 
			
		||||
from tests.common import MockConfigEntry, async_fire_time_changed
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.usefixtures("init_integration")
 | 
			
		||||
async def test_load_unload_config_entry(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test loading and unloading the integration."""
 | 
			
		||||
    assert mock_config_entry.state is ConfigEntryState.LOADED
 | 
			
		||||
 | 
			
		||||
    await hass.config_entries.async_unload(mock_config_entry.entry_id)
 | 
			
		||||
    await hass.async_block_till_done()
 | 
			
		||||
 | 
			
		||||
    assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.usefixtures("init_integration")
 | 
			
		||||
async def test_device_info(
 | 
			
		||||
    snapshot: SnapshotAssertion,
 | 
			
		||||
    device_registry: dr.DeviceRegistry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test device registry integration."""
 | 
			
		||||
    device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
 | 
			
		||||
    assert device_entry is not None
 | 
			
		||||
    assert device_entry == snapshot
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    ("exception", "expected_state"),
 | 
			
		||||
    [
 | 
			
		||||
        (growattServer.GrowattV1ApiError("API Error"), ConfigEntryState.SETUP_ERROR),
 | 
			
		||||
        (
 | 
			
		||||
            json.decoder.JSONDecodeError("Invalid JSON", "", 0),
 | 
			
		||||
            ConfigEntryState.SETUP_ERROR,
 | 
			
		||||
        ),
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
async def test_setup_error_on_api_failure(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_growatt_v1_api,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
    exception: Exception,
 | 
			
		||||
    expected_state: ConfigEntryState,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test setup error on API failures during device list."""
 | 
			
		||||
    mock_growatt_v1_api.device_list.side_effect = exception
 | 
			
		||||
 | 
			
		||||
    await setup_integration(hass, mock_config_entry)
 | 
			
		||||
 | 
			
		||||
    assert mock_config_entry.state is expected_state
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.usefixtures("init_integration")
 | 
			
		||||
async def test_coordinator_update_failed(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_growatt_v1_api,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
    freezer: FrozenDateTimeFactory,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test coordinator handles update failures gracefully."""
 | 
			
		||||
    # Integration should be loaded
 | 
			
		||||
    assert mock_config_entry.state is ConfigEntryState.LOADED
 | 
			
		||||
 | 
			
		||||
    # Cause coordinator update to fail
 | 
			
		||||
    mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError(
 | 
			
		||||
        "Connection timeout"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Trigger coordinator refresh
 | 
			
		||||
    freezer.tick(timedelta(minutes=5))
 | 
			
		||||
    async_fire_time_changed(hass)
 | 
			
		||||
    await hass.async_block_till_done(wait_background_tasks=True)
 | 
			
		||||
 | 
			
		||||
    # Integration should remain loaded despite coordinator error
 | 
			
		||||
    assert mock_config_entry.state is ConfigEntryState.LOADED
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_classic_api_setup(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    snapshot: SnapshotAssertion,
 | 
			
		||||
    mock_growatt_classic_api,
 | 
			
		||||
    mock_config_entry_classic: MockConfigEntry,
 | 
			
		||||
    device_registry: dr.DeviceRegistry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test integration setup with Classic API (password auth)."""
 | 
			
		||||
    # Classic API doesn't support MIN devices - use TLX device instead
 | 
			
		||||
    mock_growatt_classic_api.device_list.return_value = [
 | 
			
		||||
        {"deviceSn": "TLX123456", "deviceType": "tlx"}
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    await setup_integration(hass, mock_config_entry_classic)
 | 
			
		||||
 | 
			
		||||
    assert mock_config_entry_classic.state is ConfigEntryState.LOADED
 | 
			
		||||
 | 
			
		||||
    # Verify Classic API login was called
 | 
			
		||||
    mock_growatt_classic_api.login.assert_called()
 | 
			
		||||
 | 
			
		||||
    # Verify device was created
 | 
			
		||||
    device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")})
 | 
			
		||||
    assert device_entry is not None
 | 
			
		||||
    assert device_entry == snapshot
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.usefixtures("init_integration")
 | 
			
		||||
async def test_unload_removes_listeners(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that unloading removes all listeners."""
 | 
			
		||||
    # Get initial listener count
 | 
			
		||||
    initial_listeners = len(hass.bus.async_listeners())
 | 
			
		||||
 | 
			
		||||
    # Unload the integration
 | 
			
		||||
    await hass.config_entries.async_unload(mock_config_entry.entry_id)
 | 
			
		||||
    await hass.async_block_till_done()
 | 
			
		||||
 | 
			
		||||
    # Verify listeners were removed (should be same or less)
 | 
			
		||||
    final_listeners = len(hass.bus.async_listeners())
 | 
			
		||||
    assert final_listeners <= initial_listeners
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_multiple_devices_discovered(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    snapshot: SnapshotAssertion,
 | 
			
		||||
    mock_growatt_v1_api,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
    device_registry: dr.DeviceRegistry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test handling multiple devices from device_list."""
 | 
			
		||||
    # Reset and add multiple devices
 | 
			
		||||
    mock_config_entry_new = MockConfigEntry(
 | 
			
		||||
        domain=DOMAIN,
 | 
			
		||||
        data=mock_config_entry.data,
 | 
			
		||||
        unique_id="plant_456",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    mock_growatt_v1_api.device_list.return_value = {
 | 
			
		||||
        "devices": [
 | 
			
		||||
            {"device_sn": "MIN123456", "type": 7},
 | 
			
		||||
            {"device_sn": "MIN789012", "type": 7},
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    with patch(
 | 
			
		||||
        "homeassistant.components.growatt_server.coordinator.SCAN_INTERVAL",
 | 
			
		||||
        timedelta(minutes=5),
 | 
			
		||||
    ):
 | 
			
		||||
        await setup_integration(hass, mock_config_entry_new)
 | 
			
		||||
 | 
			
		||||
    # Verify both devices were created
 | 
			
		||||
    device1 = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
 | 
			
		||||
    device2 = device_registry.async_get_device(identifiers={(DOMAIN, "MIN789012")})
 | 
			
		||||
 | 
			
		||||
    assert device1 is not None
 | 
			
		||||
    assert device1 == snapshot(name="device_min123456")
 | 
			
		||||
    assert device2 is not None
 | 
			
		||||
    assert device2 == snapshot(name="device_min789012")
 | 
			
		||||
							
								
								
									
										133
									
								
								tests/components/growatt_server/test_sensor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								tests/components/growatt_server/test_sensor.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,133 @@
 | 
			
		||||
"""Tests for the Growatt Server sensor platform."""
 | 
			
		||||
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
from freezegun.api import FrozenDateTimeFactory
 | 
			
		||||
import growattServer
 | 
			
		||||
import pytest
 | 
			
		||||
from syrupy.assertion import SnapshotAssertion
 | 
			
		||||
 | 
			
		||||
from homeassistant.const import STATE_UNAVAILABLE, Platform
 | 
			
		||||
from homeassistant.core import HomeAssistant
 | 
			
		||||
from homeassistant.helpers import entity_registry as er
 | 
			
		||||
 | 
			
		||||
from . import setup_integration
 | 
			
		||||
 | 
			
		||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
 | 
			
		||||
async def test_all_sensors(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    snapshot: SnapshotAssertion,
 | 
			
		||||
    mock_growatt_v1_api,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
    entity_registry: er.EntityRegistry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test all sensor entities with snapshot."""
 | 
			
		||||
    with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
 | 
			
		||||
        await setup_integration(hass, mock_config_entry)
 | 
			
		||||
 | 
			
		||||
    await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_sensor_coordinator_updates(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_growatt_v1_api,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
    freezer: FrozenDateTimeFactory,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test sensors update when coordinator refreshes."""
 | 
			
		||||
    with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
 | 
			
		||||
        await setup_integration(hass, mock_config_entry)
 | 
			
		||||
 | 
			
		||||
    # Verify sensor exists
 | 
			
		||||
    state = hass.states.get("sensor.test_plant_total_energy_today")
 | 
			
		||||
    assert state is not None
 | 
			
		||||
    assert state.state == "12.5"
 | 
			
		||||
 | 
			
		||||
    # Update mock data
 | 
			
		||||
    mock_growatt_v1_api.plant_energy_overview.return_value = {
 | 
			
		||||
        "today_energy": 25.0,  # Changed from 12.5
 | 
			
		||||
        "total_energy": 1250.0,
 | 
			
		||||
        "current_power": 2500,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # Trigger coordinator refresh
 | 
			
		||||
    freezer.tick(timedelta(minutes=5))
 | 
			
		||||
    async_fire_time_changed(hass)
 | 
			
		||||
    await hass.async_block_till_done(wait_background_tasks=True)
 | 
			
		||||
 | 
			
		||||
    # Verify state updated
 | 
			
		||||
    state = hass.states.get("sensor.test_plant_total_energy_today")
 | 
			
		||||
    assert state is not None
 | 
			
		||||
    assert state.state == "25.0"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async def test_sensor_unavailable_on_coordinator_error(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    mock_growatt_v1_api,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
    freezer: FrozenDateTimeFactory,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test sensors become unavailable when coordinator fails."""
 | 
			
		||||
    with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
 | 
			
		||||
        await setup_integration(hass, mock_config_entry)
 | 
			
		||||
 | 
			
		||||
    # Verify sensor is initially available
 | 
			
		||||
    state = hass.states.get("sensor.min123456_all_batteries_charged_today")
 | 
			
		||||
    assert state is not None
 | 
			
		||||
    assert state.state != STATE_UNAVAILABLE
 | 
			
		||||
 | 
			
		||||
    # Cause coordinator update to fail
 | 
			
		||||
    mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError(
 | 
			
		||||
        "Connection timeout"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Trigger coordinator refresh
 | 
			
		||||
    freezer.tick(timedelta(minutes=5))
 | 
			
		||||
    async_fire_time_changed(hass)
 | 
			
		||||
    await hass.async_block_till_done(wait_background_tasks=True)
 | 
			
		||||
 | 
			
		||||
    # Verify sensor becomes unavailable
 | 
			
		||||
    state = hass.states.get("sensor.min123456_all_batteries_charged_today")
 | 
			
		||||
    assert state is not None
 | 
			
		||||
    assert state.state == STATE_UNAVAILABLE
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
 | 
			
		||||
async def test_total_sensors_classic_api(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    snapshot: SnapshotAssertion,
 | 
			
		||||
    mock_growatt_classic_api,
 | 
			
		||||
    mock_config_entry_classic: MockConfigEntry,
 | 
			
		||||
    entity_registry: er.EntityRegistry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test total sensors with Classic API."""
 | 
			
		||||
    # Classic API uses TLX devices
 | 
			
		||||
    mock_growatt_classic_api.device_list.return_value = [
 | 
			
		||||
        {"deviceSn": "TLX123456", "deviceType": "tlx"}
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
 | 
			
		||||
        await setup_integration(hass, mock_config_entry_classic)
 | 
			
		||||
 | 
			
		||||
    await snapshot_platform(
 | 
			
		||||
        hass, entity_registry, snapshot, mock_config_entry_classic.entry_id
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
 | 
			
		||||
async def test_sensor_entity_registry(
 | 
			
		||||
    hass: HomeAssistant,
 | 
			
		||||
    snapshot: SnapshotAssertion,
 | 
			
		||||
    mock_growatt_v1_api,
 | 
			
		||||
    mock_config_entry: MockConfigEntry,
 | 
			
		||||
    entity_registry: er.EntityRegistry,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test sensor entities are properly registered."""
 | 
			
		||||
    with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
 | 
			
		||||
        await setup_integration(hass, mock_config_entry)
 | 
			
		||||
 | 
			
		||||
    await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user