Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ on:
type: boolean

env:
CACHE_VERSION: 1
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.12"
Expand Down
34 changes: 31 additions & 3 deletions homeassistant/components/energy/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import asyncio
from collections import Counter
from collections.abc import Awaitable, Callable
from typing import Literal, TypedDict
from typing import Literal, NotRequired, TypedDict

import voluptuous as vol

Expand All @@ -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 a an energy meter (kWh)
# statistic_id of an energy meter (kWh)
stat_energy_from: str

# statistic_id of costs ($) incurred from the energy meter
Expand Down Expand Up @@ -58,13 +58,22 @@ 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_rate: str


class GridSourceType(TypedDict):
"""Dictionary holding the source of grid energy consumption."""

type: Literal["grid"]

flow_from: list[FlowFromGridSourceType]
flow_to: list[FlowToGridSourceType]
power: NotRequired[list[GridPowerSourceType]]

cost_adjustment_day: float

Expand All @@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
type: Literal["solar"]

stat_energy_from: str
stat_rate: NotRequired[str]
config_entry_solar_forecast: list[str] | None


Expand All @@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):

stat_energy_from: str
stat_energy_to: str
# positive when discharging, negative when charging
stat_rate: NotRequired[str]


class GasSourceType(TypedDict):
Expand Down Expand Up @@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value
stat_consumption: str

# Instantaneous rate of flow: W, L/min or m³/h
stat_rate: 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: str | None
included_in_stat: NotRequired[str]


class EnergyPreferences(TypedDict):
Expand Down Expand Up @@ -194,6 +209,12 @@ def _flow_from_ensure_single_price(
}
)

GRID_POWER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_rate"): str,
}
)


def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
"""Generate a validator that ensures a value is only used once."""
Expand Down Expand Up @@ -224,13 +245,18 @@ def validate_uniqueness(
[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_rate"),
),
vol.Required("cost_adjustment_day"): vol.Coerce(float),
}
)
SOLAR_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "solar",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
}
)
Expand All @@ -239,6 +265,7 @@ def validate_uniqueness(
vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str,
vol.Optional("stat_rate"): str,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
Expand Down Expand Up @@ -294,6 +321,7 @@ def check_type_limits(value: list[SourceType]) -> list[SourceType]:
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
vol.Optional("stat_rate"): str,
vol.Optional("name"): str,
vol.Optional("included_in_stat"): str,
}
Expand Down
110 changes: 101 additions & 9 deletions homeassistant/components/energy/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
Expand All @@ -23,12 +24,17 @@
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,
Expand Down Expand Up @@ -82,6 +88,10 @@ 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]),
Expand Down Expand Up @@ -159,45 +169,49 @@ def as_dict(self) -> dict:


@callback
def _async_validate_usage_stat(
def _async_validate_stat_common(
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."""
check_negative: bool = False,
) -> str | None:
"""Validate common aspects of a statistic.

Returns the entity_id if validation succeeds, None otherwise.
"""
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
return None

entity_id = stat_id

if not recorder.is_entity_recorded(hass, entity_id):
issues.add_issue(hass, "recorder_untracked", entity_id)
return
return None

if (state := hass.states.get(entity_id)) is None:
issues.add_issue(hass, "entity_not_defined", entity_id)
return
return None

if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
return
return None

try:
current_value: float | None = float(state.state)
except ValueError:
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
return
return None

if current_value is not None and current_value < 0:
if check_negative and 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)
Expand All @@ -211,6 +225,36 @@ def _async_validate_usage_stat(
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 = [
Expand Down Expand Up @@ -255,6 +299,39 @@ 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,
Expand Down Expand Up @@ -434,6 +511,21 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
)
)

for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
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(
Expand Down
7 changes: 7 additions & 0 deletions homeassistant/components/frontend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sidebar_title="climate",
sidebar_default_visible=False,
)
async_register_built_in_panel(
hass,
"home",
sidebar_icon="mdi:home",
sidebar_title="home",
sidebar_default_visible=False,
)

async_register_built_in_panel(hass, "profile")

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/mill/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/onedrive/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_failed": {
"message": "Failed to update drive state"
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/point/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}
12 changes: 5 additions & 7 deletions homeassistant/components/senz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]

type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Set up SENZ from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
Expand Down Expand Up @@ -71,16 +72,13 @@ async def update_thermostats() -> dict[str, Thermostat]:

await coordinator.async_config_entry_first_refresh()

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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: SENZConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Loading
Loading