This commit is contained in:
Paulus Schoutsen 2022-10-06 15:08:25 -04:00 committed by GitHub
commit aabd681d7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 623 additions and 424 deletions

View File

@ -162,7 +162,7 @@ build.json @home-assistant/supervisor
/tests/components/brunt/ @eavanvalkenburg /tests/components/brunt/ @eavanvalkenburg
/homeassistant/components/bsblan/ @liudger /homeassistant/components/bsblan/ @liudger
/tests/components/bsblan/ @liudger /tests/components/bsblan/ @liudger
/homeassistant/components/bt_smarthub/ @jxwolstenholme /homeassistant/components/bt_smarthub/ @typhoon2099
/homeassistant/components/bthome/ @Ernst79 /homeassistant/components/bthome/ @Ernst79
/tests/components/bthome/ @Ernst79 /tests/components/bthome/ @Ernst79
/homeassistant/components/buienradar/ @mjj4791 @ties @Robbie1221 /homeassistant/components/buienradar/ @mjj4791 @ties @Robbie1221

View File

@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import asdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
import itertools import itertools
import logging import logging
@ -185,11 +184,11 @@ class BluetoothManager:
"adapters": self._adapters, "adapters": self._adapters,
"scanners": scanner_diagnostics, "scanners": scanner_diagnostics,
"connectable_history": [ "connectable_history": [
asdict(service_info) service_info.as_dict()
for service_info in self._connectable_history.values() for service_info in self._connectable_history.values()
], ],
"history": [ "history": [
asdict(service_info) for service_info in self._history.values() service_info.as_dict() for service_info in self._history.values()
], ],
} }

View File

@ -53,6 +53,25 @@ class BluetoothServiceInfoBleak(BluetoothServiceInfo):
connectable: bool connectable: bool
time: float time: float
def as_dict(self) -> dict[str, Any]:
"""Return as dict.
The dataclass asdict method is not used because
it will try to deepcopy pyobjc data which will fail.
"""
return {
"name": self.name,
"address": self.address,
"rssi": self.rssi,
"manufacturer_data": self.manufacturer_data,
"service_data": self.service_data,
"service_uuids": self.service_uuids,
"source": self.source,
"advertisement": self.advertisement,
"connectable": self.connectable,
"time": self.time,
}
class BluetoothScanningMode(Enum): class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices.""" """The mode of scanning for bluetooth devices."""

View File

@ -2,8 +2,8 @@
"domain": "bt_smarthub", "domain": "bt_smarthub",
"name": "BT Smart Hub", "name": "BT Smart Hub",
"documentation": "https://www.home-assistant.io/integrations/bt_smarthub", "documentation": "https://www.home-assistant.io/integrations/bt_smarthub",
"requirements": ["btsmarthub_devicelist==0.2.2"], "requirements": ["btsmarthub_devicelist==0.2.3"],
"codeowners": ["@jxwolstenholme"], "codeowners": ["@typhoon2099"],
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["btsmarthub_devicelist"] "loggers": ["btsmarthub_devicelist"]
} }

View File

@ -2,7 +2,7 @@
"domain": "frontend", "domain": "frontend",
"name": "Home Assistant Frontend", "name": "Home Assistant Frontend",
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"requirements": ["home-assistant-frontend==20221005.0"], "requirements": ["home-assistant-frontend==20221006.0"],
"dependencies": [ "dependencies": [
"api", "api",
"auth", "auth",

View File

@ -23,22 +23,11 @@ from homeassistant.components.recorder.models import (
StatisticMetaData, StatisticMetaData,
StatisticResult, StatisticResult,
) )
from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import entity_sources from homeassistant.helpers.entity import entity_sources
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import (
BaseUnitConverter,
DistanceConverter,
EnergyConverter,
MassConverter,
PowerConverter,
PressureConverter,
SpeedConverter,
TemperatureConverter,
VolumeConverter,
)
from . import ( from . import (
ATTR_LAST_RESET, ATTR_LAST_RESET,
@ -48,7 +37,6 @@ from . import (
STATE_CLASS_TOTAL, STATE_CLASS_TOTAL,
STATE_CLASS_TOTAL_INCREASING, STATE_CLASS_TOTAL_INCREASING,
STATE_CLASSES, STATE_CLASSES,
SensorDeviceClass,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -59,18 +47,6 @@ DEFAULT_STATISTICS = {
STATE_CLASS_TOTAL_INCREASING: {"sum"}, STATE_CLASS_TOTAL_INCREASING: {"sum"},
} }
UNIT_CONVERTERS: dict[str, type[BaseUnitConverter]] = {
SensorDeviceClass.DISTANCE: DistanceConverter,
SensorDeviceClass.ENERGY: EnergyConverter,
SensorDeviceClass.GAS: VolumeConverter,
SensorDeviceClass.POWER: PowerConverter,
SensorDeviceClass.PRESSURE: PressureConverter,
SensorDeviceClass.SPEED: SpeedConverter,
SensorDeviceClass.TEMPERATURE: TemperatureConverter,
SensorDeviceClass.VOLUME: VolumeConverter,
SensorDeviceClass.WEIGHT: MassConverter,
}
# Keep track of entities for which a warning about decreasing value has been logged # Keep track of entities for which a warning about decreasing value has been logged
SEEN_DIP = "sensor_seen_total_increasing_dip" SEEN_DIP = "sensor_seen_total_increasing_dip"
WARN_DIP = "sensor_warn_total_increasing_dip" WARN_DIP = "sensor_warn_total_increasing_dip"
@ -154,84 +130,91 @@ def _normalize_states(
session: Session, session: Session,
old_metadatas: dict[str, tuple[int, StatisticMetaData]], old_metadatas: dict[str, tuple[int, StatisticMetaData]],
entity_history: Iterable[State], entity_history: Iterable[State],
device_class: str | None,
entity_id: str, entity_id: str,
) -> tuple[str | None, str | None, list[tuple[float, State]]]: ) -> tuple[str | None, str | None, list[tuple[float, State]]]:
"""Normalize units.""" """Normalize units."""
old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None
state_unit: str | None = None state_unit: str | None = None
if device_class not in UNIT_CONVERTERS or ( fstates: list[tuple[float, State]] = []
old_metadata
and old_metadata["unit_of_measurement"]
not in UNIT_CONVERTERS[device_class].VALID_UNITS
):
# We're either not normalizing this device class or this entity is not stored
# in a supported unit, return the states as they are
fstates = []
for state in entity_history:
try:
fstate = _parse_float(state.state)
except (ValueError, TypeError): # TypeError to guard for NULL state in DB
continue
fstates.append((fstate, state))
if fstates:
all_units = _get_units(fstates)
if len(all_units) > 1:
if WARN_UNSTABLE_UNIT not in hass.data:
hass.data[WARN_UNSTABLE_UNIT] = set()
if entity_id not in hass.data[WARN_UNSTABLE_UNIT]:
hass.data[WARN_UNSTABLE_UNIT].add(entity_id)
extra = ""
if old_metadata:
extra = (
" and matches the unit of already compiled statistics "
f"({old_metadata['unit_of_measurement']})"
)
_LOGGER.warning(
"The unit of %s is changing, got multiple %s, generation of long term "
"statistics will be suppressed unless the unit is stable%s. "
"Go to %s to fix this",
entity_id,
all_units,
extra,
LINK_DEV_STATISTICS,
)
return None, None, []
state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
return state_unit, state_unit, fstates
converter = UNIT_CONVERTERS[device_class]
fstates = []
statistics_unit: str | None = None
if old_metadata:
statistics_unit = old_metadata["unit_of_measurement"]
for state in entity_history: for state in entity_history:
try: try:
fstate = _parse_float(state.state) fstate = _parse_float(state.state)
except ValueError: except (ValueError, TypeError): # TypeError to guard for NULL state in DB
continue continue
fstates.append((fstate, state))
if not fstates:
return None, None, fstates
state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
statistics_unit: str | None
if not old_metadata:
# We've not seen this sensor before, the first valid state determines the unit
# used for statistics
statistics_unit = state_unit
else:
# We have seen this sensor before, use the unit from metadata
statistics_unit = old_metadata["unit_of_measurement"]
if (
not statistics_unit
or statistics_unit not in statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER
):
# The unit used by this sensor doesn't support unit conversion
all_units = _get_units(fstates)
if len(all_units) > 1:
if WARN_UNSTABLE_UNIT not in hass.data:
hass.data[WARN_UNSTABLE_UNIT] = set()
if entity_id not in hass.data[WARN_UNSTABLE_UNIT]:
hass.data[WARN_UNSTABLE_UNIT].add(entity_id)
extra = ""
if old_metadata:
extra = (
" and matches the unit of already compiled statistics "
f"({old_metadata['unit_of_measurement']})"
)
_LOGGER.warning(
"The unit of %s is changing, got multiple %s, generation of long term "
"statistics will be suppressed unless the unit is stable%s. "
"Go to %s to fix this",
entity_id,
all_units,
extra,
LINK_DEV_STATISTICS,
)
return None, None, []
state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT)
return state_unit, state_unit, fstates
converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER[statistics_unit]
valid_fstates: list[tuple[float, State]] = []
for fstate, state in fstates:
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
# Exclude unsupported units from statistics # Exclude states with unsupported unit from statistics
if state_unit not in converter.VALID_UNITS: if state_unit not in converter.VALID_UNITS:
if WARN_UNSUPPORTED_UNIT not in hass.data: if WARN_UNSUPPORTED_UNIT not in hass.data:
hass.data[WARN_UNSUPPORTED_UNIT] = set() hass.data[WARN_UNSUPPORTED_UNIT] = set()
if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]:
hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id) hass.data[WARN_UNSUPPORTED_UNIT].add(entity_id)
_LOGGER.warning( _LOGGER.warning(
"%s has unit %s which is unsupported for device_class %s", "The unit of %s (%s) can not be converted to the unit of previously "
"compiled statistics (%s). Generation of long term statistics "
"will be suppressed unless the unit changes back to %s or a "
"compatible unit. "
"Go to %s to fix this",
entity_id, entity_id,
state_unit, state_unit,
device_class, statistics_unit,
statistics_unit,
LINK_DEV_STATISTICS,
) )
continue continue
if statistics_unit is None:
statistics_unit = state_unit
fstates.append( valid_fstates.append(
( (
converter.convert( converter.convert(
fstate, from_unit=state_unit, to_unit=statistics_unit fstate, from_unit=state_unit, to_unit=statistics_unit
@ -240,7 +223,7 @@ def _normalize_states(
) )
) )
return statistics_unit, state_unit, fstates return statistics_unit, state_unit, valid_fstates
def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str:
@ -427,14 +410,12 @@ def _compile_statistics( # noqa: C901
if entity_id not in history_list: if entity_id not in history_list:
continue continue
device_class = _state.attributes.get(ATTR_DEVICE_CLASS)
entity_history = history_list[entity_id] entity_history = history_list[entity_id]
statistics_unit, state_unit, fstates = _normalize_states( statistics_unit, state_unit, fstates = _normalize_states(
hass, hass,
session, session,
old_metadatas, old_metadatas,
entity_history, entity_history,
device_class,
entity_id, entity_id,
) )
@ -467,11 +448,11 @@ def _compile_statistics( # noqa: C901
if entity_id not in hass.data[WARN_UNSTABLE_UNIT]: if entity_id not in hass.data[WARN_UNSTABLE_UNIT]:
hass.data[WARN_UNSTABLE_UNIT].add(entity_id) hass.data[WARN_UNSTABLE_UNIT].add(entity_id)
_LOGGER.warning( _LOGGER.warning(
"The %sunit of %s (%s) does not match the unit of already " "The unit of %s (%s) can not be converted to the unit of previously "
"compiled statistics (%s). Generation of long term statistics " "compiled statistics (%s). Generation of long term statistics "
"will be suppressed unless the unit changes back to %s. " "will be suppressed unless the unit changes back to %s or a "
"compatible unit. "
"Go to %s to fix this", "Go to %s to fix this",
"normalized " if device_class in UNIT_CONVERTERS else "",
entity_id, entity_id,
statistics_unit, statistics_unit,
old_metadata[1]["unit_of_measurement"], old_metadata[1]["unit_of_measurement"],
@ -603,7 +584,6 @@ def list_statistic_ids(
for state in entities: for state in entities:
state_class = state.attributes[ATTR_STATE_CLASS] state_class = state.attributes[ATTR_STATE_CLASS]
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
provided_statistics = DEFAULT_STATISTICS[state_class] provided_statistics = DEFAULT_STATISTICS[state_class]
@ -620,21 +600,6 @@ def list_statistic_ids(
): ):
continue continue
if device_class not in UNIT_CONVERTERS:
result[state.entity_id] = {
"has_mean": "mean" in provided_statistics,
"has_sum": "sum" in provided_statistics,
"name": None,
"source": RECORDER_DOMAIN,
"statistic_id": state.entity_id,
"unit_of_measurement": state_unit,
}
continue
converter = UNIT_CONVERTERS[device_class]
if state_unit not in converter.VALID_UNITS:
continue
result[state.entity_id] = { result[state.entity_id] = {
"has_mean": "mean" in provided_statistics, "has_mean": "mean" in provided_statistics,
"has_sum": "sum" in provided_statistics, "has_sum": "sum" in provided_statistics,
@ -643,6 +608,7 @@ def list_statistic_ids(
"statistic_id": state.entity_id, "statistic_id": state.entity_id,
"unit_of_measurement": state_unit, "unit_of_measurement": state_unit,
} }
continue
return result return result
@ -660,7 +626,6 @@ def validate_statistics(
for state in sensor_states: for state in sensor_states:
entity_id = state.entity_id entity_id = state.entity_id
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
state_class = state.attributes.get(ATTR_STATE_CLASS) state_class = state.attributes.get(ATTR_STATE_CLASS)
state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@ -684,35 +649,30 @@ def validate_statistics(
) )
metadata_unit = metadata[1]["unit_of_measurement"] metadata_unit = metadata[1]["unit_of_measurement"]
if device_class not in UNIT_CONVERTERS: converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER.get(metadata_unit)
if not converter:
if state_unit != metadata_unit: if state_unit != metadata_unit:
# The unit has changed # The unit has changed, and it's not possible to convert
issue_type = (
"units_changed_can_convert"
if statistics.can_convert_units(metadata_unit, state_unit)
else "units_changed"
)
validation_result[entity_id].append( validation_result[entity_id].append(
statistics.ValidationIssue( statistics.ValidationIssue(
issue_type, "units_changed",
{ {
"statistic_id": entity_id, "statistic_id": entity_id,
"state_unit": state_unit, "state_unit": state_unit,
"metadata_unit": metadata_unit, "metadata_unit": metadata_unit,
"supported_unit": metadata_unit,
}, },
) )
) )
elif metadata_unit not in UNIT_CONVERTERS[device_class].VALID_UNITS: elif state_unit not in converter.VALID_UNITS:
# The unit in metadata is not supported for this device class # The state unit can't be converted to the unit in metadata
valid_units = ", ".join( valid_units = ", ".join(sorted(converter.VALID_UNITS))
sorted(UNIT_CONVERTERS[device_class].VALID_UNITS)
)
validation_result[entity_id].append( validation_result[entity_id].append(
statistics.ValidationIssue( statistics.ValidationIssue(
"unsupported_unit_metadata", "units_changed",
{ {
"statistic_id": entity_id, "statistic_id": entity_id,
"device_class": device_class, "state_unit": state_unit,
"metadata_unit": metadata_unit, "metadata_unit": metadata_unit,
"supported_unit": valid_units, "supported_unit": valid_units,
}, },
@ -728,23 +688,6 @@ def validate_statistics(
) )
) )
if (
state_class in STATE_CLASSES
and device_class in UNIT_CONVERTERS
and state_unit not in UNIT_CONVERTERS[device_class].VALID_UNITS
):
# The unit in the state is not supported for this device class
validation_result[entity_id].append(
statistics.ValidationIssue(
"unsupported_unit_state",
{
"statistic_id": entity_id,
"device_class": device_class,
"state_unit": state_unit,
},
)
)
for statistic_id in sensor_statistic_ids - sensor_entity_ids: for statistic_id in sensor_statistic_ids - sensor_entity_ids:
# There is no sensor matching the statistics_id # There is no sensor matching the statistics_id
validation_result[statistic_id].append( validation_result[statistic_id].append(

View File

@ -1,6 +1,7 @@
"""Config flow for ZHA.""" """Config flow for ZHA."""
from __future__ import annotations from __future__ import annotations
import asyncio
import collections import collections
import contextlib import contextlib
import copy import copy
@ -65,8 +66,16 @@ FORMATION_UPLOAD_MANUAL_BACKUP = "upload_manual_backup"
CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup" CHOOSE_AUTOMATIC_BACKUP = "choose_automatic_backup"
OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee" OVERWRITE_COORDINATOR_IEEE = "overwrite_coordinator_ieee"
OPTIONS_INTENT_MIGRATE = "intent_migrate"
OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
UPLOADED_BACKUP_FILE = "uploaded_backup_file" UPLOADED_BACKUP_FILE = "uploaded_backup_file"
DEFAULT_ZHA_ZEROCONF_PORT = 6638
ESPHOME_API_PORT = 6053
CONNECT_DELAY_S = 1.0
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -159,6 +168,7 @@ class BaseZhaFlow(FlowHandler):
yield app yield app
finally: finally:
await app.disconnect() await app.disconnect()
await asyncio.sleep(CONNECT_DELAY_S)
async def _restore_backup( async def _restore_backup(
self, backup: zigpy.backups.NetworkBackup, **kwargs: Any self, backup: zigpy.backups.NetworkBackup, **kwargs: Any
@ -628,14 +638,21 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
# Hostname is format: livingroom.local. # Hostname is format: livingroom.local.
local_name = discovery_info.hostname[:-1] local_name = discovery_info.hostname[:-1]
radio_type = discovery_info.properties.get("radio_type") or local_name port = discovery_info.port or DEFAULT_ZHA_ZEROCONF_PORT
# Fix incorrect port for older TubesZB devices
if "tube" in local_name and port == ESPHOME_API_PORT:
port = DEFAULT_ZHA_ZEROCONF_PORT
if "radio_type" in discovery_info.properties:
self._radio_type = RadioType[discovery_info.properties["radio_type"]]
elif "efr32" in local_name:
self._radio_type = RadioType.ezsp
else:
self._radio_type = RadioType.znp
node_name = local_name[: -len(".local")] node_name = local_name[: -len(".local")]
host = discovery_info.host device_path = f"socket://{discovery_info.host}:{port}"
port = discovery_info.port
if local_name.startswith("tube") or "efr32" in local_name:
# This is hard coded to work with legacy devices
port = 6638
device_path = f"socket://{host}:{port}"
if current_entry := await self.async_set_unique_id(node_name): if current_entry := await self.async_set_unique_id(node_name):
self._abort_if_unique_id_configured( self._abort_if_unique_id_configured(
@ -651,13 +668,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
self._title = device_path self._title = device_path
self._device_path = device_path self._device_path = device_path
if "efr32" in radio_type:
self._radio_type = RadioType.ezsp
elif "zigate" in radio_type:
self._radio_type = RadioType.zigate
else:
self._radio_type = RadioType.znp
return await self.async_step_confirm() return await self.async_step_confirm()
async def async_step_hardware( async def async_step_hardware(
@ -720,10 +730,54 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, config_entries.OptionsFlow):
# ZHA is not running # ZHA is not running
pass pass
return await self.async_step_choose_serial_port() return await self.async_step_prompt_migrate_or_reconfigure()
return self.async_show_form(step_id="init") return self.async_show_form(step_id="init")
async def async_step_prompt_migrate_or_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm if we are migrating adapters or just re-configuring."""
return self.async_show_menu(
step_id="prompt_migrate_or_reconfigure",
menu_options=[
OPTIONS_INTENT_RECONFIGURE,
OPTIONS_INTENT_MIGRATE,
],
)
async def async_step_intent_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Virtual step for when the user is reconfiguring the integration."""
return await self.async_step_choose_serial_port()
async def async_step_intent_migrate(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm the user wants to reset their current radio."""
if user_input is not None:
# Reset the current adapter
async with self._connect_zigpy_app() as app:
await app.reset_network_info()
return await self.async_step_instruct_unplug()
return self.async_show_form(step_id="intent_migrate")
async def async_step_instruct_unplug(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Instruct the user to unplug the current radio, if possible."""
if user_input is not None:
# Now that the old radio is gone, we can scan for serial ports again
return await self.async_step_choose_serial_port()
return self.async_show_form(step_id="instruct_unplug")
async def _async_create_radio_entity(self): async def _async_create_radio_entity(self):
"""Re-implementation of the base flow's final step to update the config.""" """Re-implementation of the base flow's final step to update the config."""
device_settings = self._device_settings.copy() device_settings = self._device_settings.copy()

View File

@ -76,6 +76,22 @@
"title": "Reconfigure ZHA", "title": "Reconfigure ZHA",
"description": "ZHA will be stopped. Do you wish to continue?" "description": "ZHA will be stopped. Do you wish to continue?"
}, },
"prompt_migrate_or_reconfigure": {
"title": "Migrate or re-configure",
"description": "Are you migrating to a new radio or re-configuring the current radio?",
"menu_options": {
"intent_migrate": "Migrate to a new radio",
"intent_reconfigure": "Re-configure the current radio"
}
},
"intent_migrate": {
"title": "Migrate to a new radio",
"description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?"
},
"instruct_unplug": {
"title": "Unplug your old radio",
"description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it."
},
"choose_serial_port": { "choose_serial_port": {
"title": "[%key:component::zha::config::step::choose_serial_port::title%]", "title": "[%key:component::zha::config::step::choose_serial_port::title%]",
"data": { "data": {

View File

@ -64,35 +64,12 @@
"description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.",
"title": "Overwrite Radio IEEE Address" "title": "Overwrite Radio IEEE Address"
}, },
"pick_radio": {
"data": {
"radio_type": "Radio Type"
},
"description": "Pick a type of your Zigbee radio",
"title": "Radio Type"
},
"port_config": {
"data": {
"baudrate": "port speed",
"flow_control": "data flow control",
"path": "Serial device path"
},
"description": "Enter port specific settings",
"title": "Settings"
},
"upload_manual_backup": { "upload_manual_backup": {
"data": { "data": {
"uploaded_backup_file": "Upload a file" "uploaded_backup_file": "Upload a file"
}, },
"description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.", "description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.",
"title": "Upload a Manual Backup" "title": "Upload a Manual Backup"
},
"user": {
"data": {
"path": "Serial Device Path"
},
"description": "Select serial port for Zigbee radio",
"title": "ZHA"
} }
} }
}, },
@ -212,6 +189,14 @@
"description": "ZHA will be stopped. Do you wish to continue?", "description": "ZHA will be stopped. Do you wish to continue?",
"title": "Reconfigure ZHA" "title": "Reconfigure ZHA"
}, },
"instruct_unplug": {
"description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.",
"title": "Unplug your old radio"
},
"intent_migrate": {
"description": "Your old radio will be factory reset. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\nDo you wish to continue?",
"title": "Migrate to a new radio"
},
"manual_pick_radio_type": { "manual_pick_radio_type": {
"data": { "data": {
"radio_type": "Radio Type" "radio_type": "Radio Type"
@ -235,6 +220,14 @@
"description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.",
"title": "Overwrite Radio IEEE Address" "title": "Overwrite Radio IEEE Address"
}, },
"prompt_migrate_or_reconfigure": {
"description": "Are you migrating to a new radio or re-configuring the current radio?",
"menu_options": {
"intent_migrate": "Migrate to a new radio",
"intent_reconfigure": "Re-configure the current radio"
},
"title": "Migrate or re-configure"
},
"upload_manual_backup": { "upload_manual_backup": {
"data": { "data": {
"uploaded_backup_file": "Upload a file" "uploaded_backup_file": "Upload a file"

View File

@ -8,7 +8,7 @@ from .backports.enum import StrEnum
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2022 MAJOR_VERSION: Final = 2022
MINOR_VERSION: Final = 10 MINOR_VERSION: Final = 10
PATCH_VERSION: Final = "0" PATCH_VERSION: Final = "1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0)

View File

@ -21,7 +21,7 @@ dbus-fast==1.24.0
fnvhash==0.1.0 fnvhash==0.1.0
hass-nabucasa==0.56.0 hass-nabucasa==0.56.0
home-assistant-bluetooth==1.3.0 home-assistant-bluetooth==1.3.0
home-assistant-frontend==20221005.0 home-assistant-frontend==20221006.0
httpx==0.23.0 httpx==0.23.0
ifaddr==0.1.7 ifaddr==0.1.7
jinja2==3.1.2 jinja2==3.1.2

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2022.10.0" version = "2022.10.1"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"

View File

@ -475,7 +475,7 @@ bthome-ble==1.2.2
bthomehub5-devicelist==0.1.1 bthomehub5-devicelist==0.1.1
# homeassistant.components.bt_smarthub # homeassistant.components.bt_smarthub
btsmarthub_devicelist==0.2.2 btsmarthub_devicelist==0.2.3
# homeassistant.components.buienradar # homeassistant.components.buienradar
buienradar==1.0.5 buienradar==1.0.5
@ -865,7 +865,7 @@ hole==0.7.0
holidays==0.16 holidays==0.16
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20221005.0 home-assistant-frontend==20221006.0
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.7.2 homeconnect==0.7.2

View File

@ -645,7 +645,7 @@ hole==0.7.0
holidays==0.16 holidays==0.16
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20221005.0 home-assistant-frontend==20221006.0
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.7.2 homeconnect==0.7.2

View File

@ -3,11 +3,13 @@
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
from bleak.backends.scanner import BLEDevice from bleak.backends.scanner import AdvertisementData, BLEDevice
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.const import DEFAULT_ADDRESS from homeassistant.components.bluetooth.const import DEFAULT_ADDRESS
from . import inject_advertisement
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
@ -158,6 +160,10 @@ async def test_diagnostics_macos(
# because we cannot import the scanner class directly without it throwing an # because we cannot import the scanner class directly without it throwing an
# error if the test is not running on linux since we won't have the correct # error if the test is not running on linux since we won't have the correct
# deps installed when testing on MacOS. # deps installed when testing on MacOS.
switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand")
switchbot_adv = AdvertisementData(
local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}
)
with patch( with patch(
"homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices", "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices",
@ -180,6 +186,8 @@ async def test_diagnostics_macos(
assert await hass.config_entries.async_setup(entry1.entry_id) assert await hass.config_entries.async_setup(entry1.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
inject_advertisement(hass, switchbot_device, switchbot_adv)
diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1)
assert diag == { assert diag == {
"adapters": { "adapters": {
@ -197,8 +205,34 @@ async def test_diagnostics_macos(
"sw_version": ANY, "sw_version": ANY,
} }
}, },
"connectable_history": [], "connectable_history": [
"history": [], {
"address": "44:44:33:11:23:45",
"advertisement": ANY,
"connectable": True,
"manufacturer_data": ANY,
"name": "wohand",
"rssi": 0,
"service_data": {},
"service_uuids": [],
"source": "local",
"time": ANY,
}
],
"history": [
{
"address": "44:44:33:11:23:45",
"advertisement": ANY,
"connectable": True,
"manufacturer_data": ANY,
"name": "wohand",
"rssi": 0,
"service_data": {},
"service_uuids": [],
"source": "local",
"time": ANY,
}
],
"scanners": [ "scanners": [
{ {
"adapter": "Core Bluetooth", "adapter": "Core Bluetooth",

View File

@ -238,8 +238,8 @@ def test_compile_hourly_statistics_purged_state_changes(
@pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES])
def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes): def test_compile_hourly_statistics_wrong_unit(hass_recorder, caplog, attributes):
"""Test compiling hourly statistics for unsupported sensor.""" """Test compiling hourly statistics for sensor with unit not matching device class."""
zero = dt_util.utcnow() zero = dt_util.utcnow()
hass = hass_recorder() hass = hass_recorder()
setup_component(hass, "sensor", {}) setup_component(hass, "sensor", {})
@ -286,6 +286,24 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes
"statistics_unit_of_measurement": "°C", "statistics_unit_of_measurement": "°C",
"unit_class": "temperature", "unit_class": "temperature",
}, },
{
"has_mean": True,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": "sensor.test2",
"statistics_unit_of_measurement": "invalid",
"unit_class": None,
},
{
"has_mean": True,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": "sensor.test3",
"statistics_unit_of_measurement": None,
"unit_class": None,
},
{ {
"statistic_id": "sensor.test6", "statistic_id": "sensor.test6",
"has_mean": True, "has_mean": True,
@ -320,6 +338,32 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes
"sum": None, "sum": None,
} }
], ],
"sensor.test2": [
{
"statistic_id": "sensor.test2",
"start": process_timestamp_to_utc_isoformat(zero),
"end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)),
"mean": 13.05084745762712,
"min": -10.0,
"max": 30.0,
"last_reset": None,
"state": None,
"sum": None,
}
],
"sensor.test3": [
{
"statistic_id": "sensor.test3",
"start": process_timestamp_to_utc_isoformat(zero),
"end": process_timestamp_to_utc_isoformat(zero + timedelta(minutes=5)),
"mean": 13.05084745762712,
"min": -10.0,
"max": 30.0,
"last_reset": None,
"state": None,
"sum": None,
}
],
"sensor.test6": [ "sensor.test6": [
{ {
"statistic_id": "sensor.test6", "statistic_id": "sensor.test6",
@ -835,32 +879,44 @@ def test_compile_hourly_sum_statistics_nan_inf_state(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"entity_id,warning_1,warning_2", "entity_id, device_class, state_unit, display_unit, statistics_unit, unit_class, offset, warning_1, warning_2",
[ [
( (
"sensor.test1", "sensor.test1",
"energy",
"kWh",
"kWh",
"kWh",
"energy",
0,
"", "",
"bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue", "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue",
), ),
( (
"sensor.power_consumption", "sensor.power_consumption",
"power",
"W",
"W",
"W",
"power",
15,
"from integration demo ", "from integration demo ",
"bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22", "bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+demo%22",
), ),
( (
"sensor.custom_sensor", "sensor.custom_sensor",
"energy",
"kWh",
"kWh",
"kWh",
"energy",
0,
"from integration test ", "from integration test ",
"report it to the custom integration author", "report it to the custom integration author",
), ),
], ],
) )
@pytest.mark.parametrize("state_class", ["total_increasing"]) @pytest.mark.parametrize("state_class", ["total_increasing"])
@pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, factor",
[
("energy", "kWh", "kWh", "kWh", "energy", 1),
],
)
def test_compile_hourly_sum_statistics_negative_state( def test_compile_hourly_sum_statistics_negative_state(
hass_recorder, hass_recorder,
caplog, caplog,
@ -873,7 +929,7 @@ def test_compile_hourly_sum_statistics_negative_state(
display_unit, display_unit,
statistics_unit, statistics_unit,
unit_class, unit_class,
factor, offset,
): ):
"""Test compiling hourly statistics with negative states.""" """Test compiling hourly statistics with negative states."""
zero = dt_util.utcnow() zero = dt_util.utcnow()
@ -938,8 +994,8 @@ def test_compile_hourly_sum_statistics_negative_state(
"mean": None, "mean": None,
"min": None, "min": None,
"last_reset": None, "last_reset": None,
"state": approx(factor * seq[7]), "state": approx(seq[7]),
"sum": approx(factor * 15), # (15 - 10) + (10 - 0) "sum": approx(offset + 15), # (20 - 15) + (10 - 0)
}, },
] ]
assert "Error while processing event StatisticsTask" not in caplog.text assert "Error while processing event StatisticsTask" not in caplog.text
@ -1844,12 +1900,13 @@ def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device_class, state_unit, display_unit, statistics_unit, unit_class, mean, min, max", "device_class, state_unit, state_unit2, unit_class, mean, min, max",
[ [
(None, None, None, None, None, 13.050847, -10, 30), (None, None, "cats", None, 13.050847, -10, 30),
(None, "%", "%", "%", None, 13.050847, -10, 30), (None, "%", "cats", None, 13.050847, -10, 30),
("battery", "%", "%", "%", None, 13.050847, -10, 30), ("battery", "%", "cats", None, 13.050847, -10, 30),
("battery", None, None, None, None, 13.050847, -10, 30), ("battery", None, "cats", None, 13.050847, -10, 30),
(None, "kW", "Wh", "power", 13.050847, -10, 30),
], ],
) )
def test_compile_hourly_statistics_changing_units_1( def test_compile_hourly_statistics_changing_units_1(
@ -1857,8 +1914,7 @@ def test_compile_hourly_statistics_changing_units_1(
caplog, caplog,
device_class, device_class,
state_unit, state_unit,
display_unit, state_unit2,
statistics_unit,
unit_class, unit_class,
mean, mean,
min, min,
@ -1875,7 +1931,7 @@ def test_compile_hourly_statistics_changing_units_1(
"unit_of_measurement": state_unit, "unit_of_measurement": state_unit,
} }
four, states = record_states(hass, zero, "sensor.test1", attributes) four, states = record_states(hass, zero, "sensor.test1", attributes)
attributes["unit_of_measurement"] = "cats" attributes["unit_of_measurement"] = state_unit2
four, _states = record_states( four, _states = record_states(
hass, zero + timedelta(minutes=5), "sensor.test1", attributes hass, zero + timedelta(minutes=5), "sensor.test1", attributes
) )
@ -1889,7 +1945,7 @@ def test_compile_hourly_statistics_changing_units_1(
do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=zero)
wait_recording_done(hass) wait_recording_done(hass)
assert "does not match the unit of already compiled" not in caplog.text assert "can not be converted to the unit of previously" not in caplog.text
statistic_ids = list_statistic_ids(hass) statistic_ids = list_statistic_ids(hass)
assert statistic_ids == [ assert statistic_ids == [
{ {
@ -1898,7 +1954,7 @@ def test_compile_hourly_statistics_changing_units_1(
"has_sum": False, "has_sum": False,
"name": None, "name": None,
"source": "recorder", "source": "recorder",
"statistics_unit_of_measurement": statistics_unit, "statistics_unit_of_measurement": state_unit,
"unit_class": unit_class, "unit_class": unit_class,
}, },
] ]
@ -1922,8 +1978,8 @@ def test_compile_hourly_statistics_changing_units_1(
do_adhoc_statistics(hass, start=zero + timedelta(minutes=10)) do_adhoc_statistics(hass, start=zero + timedelta(minutes=10))
wait_recording_done(hass) wait_recording_done(hass)
assert ( assert (
"The unit of sensor.test1 (cats) does not match the unit of already compiled " f"The unit of sensor.test1 ({state_unit2}) can not be converted to the unit of "
f"statistics ({display_unit})" in caplog.text f"previously compiled statistics ({state_unit})" in caplog.text
) )
statistic_ids = list_statistic_ids(hass) statistic_ids = list_statistic_ids(hass)
assert statistic_ids == [ assert statistic_ids == [
@ -1933,7 +1989,7 @@ def test_compile_hourly_statistics_changing_units_1(
"has_sum": False, "has_sum": False,
"name": None, "name": None,
"source": "recorder", "source": "recorder",
"statistics_unit_of_measurement": statistics_unit, "statistics_unit_of_measurement": state_unit,
"unit_class": unit_class, "unit_class": unit_class,
}, },
] ]
@ -3039,18 +3095,30 @@ def record_states(hass, zero, entity_id, attributes, seq=None):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"units, attributes, unit", "units, attributes, unit, unit2, supported_unit",
[ [
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F"), (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"),
(METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C"), (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"),
(IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "psi"), (
(METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, "Pa"), IMPERIAL_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"psi",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"Pa",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
], ],
) )
async def test_validate_statistics_supported_device_class( async def test_validate_statistics_unit_change_device_class(
hass, hass_ws_client, recorder_mock, units, attributes, unit hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit
): ):
"""Test validate_statistics.""" """Test validate_statistics."""
id = 1 id = 1
@ -3078,39 +3146,40 @@ async def test_validate_statistics_supported_device_class(
# No statistics, no state - empty response # No statistics, no state - empty response
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# No statistics, valid state - empty response # No statistics, unit in state matching device class - empty response
hass.states.async_set( hass.states.async_set(
"sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}} "sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# No statistics, invalid state - expect error # No statistics, unit in state not matching device class - empty response
hass.states.async_set( hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}} "sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
expected = { await assert_validation_result(client, {})
"sensor.test": [
{
"data": {
"device_class": attributes["device_class"],
"state_unit": "dogs",
"statistic_id": "sensor.test",
},
"type": "unsupported_unit_state",
}
],
}
await assert_validation_result(client, expected)
# Statistics has run, invalid state - expect error # Statistics has run, incompatible unit - expect error
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now) do_adhoc_statistics(hass, start=now)
hass.states.async_set( hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}} "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
expected = {
"sensor.test": [
{
"data": {
"metadata_unit": unit,
"state_unit": "dogs",
"statistic_id": "sensor.test",
"supported_unit": supported_unit,
},
"type": "units_changed",
}
],
}
await assert_validation_result(client, expected) await assert_validation_result(client, expected)
# Valid state - empty response # Valid state - empty response
@ -3125,6 +3194,18 @@ async def test_validate_statistics_supported_device_class(
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# Valid state in compatible unit - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit2}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Remove the state - empty response # Remove the state - empty response
hass.states.async_remove("sensor.test") hass.states.async_remove("sensor.test")
expected = { expected = {
@ -3144,7 +3225,7 @@ async def test_validate_statistics_supported_device_class(
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W, kW"), (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W, kW"),
], ],
) )
async def test_validate_statistics_supported_device_class_2( async def test_validate_statistics_unit_change_device_class_2(
hass, hass_ws_client, recorder_mock, units, attributes, valid_units hass, hass_ws_client, recorder_mock, units, attributes, valid_units
): ):
"""Test validate_statistics.""" """Test validate_statistics."""
@ -3173,56 +3254,144 @@ async def test_validate_statistics_supported_device_class_2(
# No statistics, no state - empty response # No statistics, no state - empty response
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# No statistics, valid state - empty response # No statistics, no device class - empty response
initial_attributes = {"state_class": "measurement"} initial_attributes = {"state_class": "measurement", "unit_of_measurement": "dogs"}
hass.states.async_set("sensor.test", 10, attributes=initial_attributes) hass.states.async_set("sensor.test", 10, attributes=initial_attributes)
await hass.async_block_till_done() await hass.async_block_till_done()
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# Statistics has run, device class set - expect error # Statistics has run, device class set not matching unit - empty response
do_adhoc_statistics(hass, start=now) do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
hass.states.async_set("sensor.test", 12, attributes=attributes)
await hass.async_block_till_done()
expected = {
"sensor.test": [
{
"data": {
"device_class": attributes["device_class"],
"metadata_unit": None,
"statistic_id": "sensor.test",
"supported_unit": valid_units,
},
"type": "unsupported_unit_metadata",
}
],
}
await assert_validation_result(client, expected)
# Invalid state too, expect double errors
hass.states.async_set( hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": "dogs"}} "sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await hass.async_block_till_done()
await assert_validation_result(client, {})
@pytest.mark.parametrize(
"units, attributes, unit, unit2, supported_unit",
[
(IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"),
(IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°F", "K", "K, °C, °F"),
(METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, "°C", "K", "K, °C, °F"),
(
IMPERIAL_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"psi",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"Pa",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
),
],
)
async def test_validate_statistics_unit_change_no_device_class(
hass, hass_ws_client, recorder_mock, units, attributes, unit, unit2, supported_unit
):
"""Test validate_statistics."""
id = 1
attributes = dict(attributes)
attributes.pop("device_class")
def next_id():
nonlocal id
id += 1
return id
async def assert_validation_result(client, expected_result):
await client.send_json(
{"id": next_id(), "type": "recorder/validate_statistics"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result
now = dt_util.utcnow()
hass.config.units = units
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
# No statistics, unit in state matching device class - empty response
hass.states.async_set(
"sensor.test", 10, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# No statistics, unit in state not matching device class - empty response
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Statistics has run, incompatible unit - expect error
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now)
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "dogs"}}
) )
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
expected = { expected = {
"sensor.test": [ "sensor.test": [
{ {
"data": { "data": {
"device_class": attributes["device_class"], "metadata_unit": unit,
"metadata_unit": None,
"statistic_id": "sensor.test",
"supported_unit": valid_units,
},
"type": "unsupported_unit_metadata",
},
{
"data": {
"device_class": attributes["device_class"],
"state_unit": "dogs", "state_unit": "dogs",
"statistic_id": "sensor.test", "statistic_id": "sensor.test",
"supported_unit": supported_unit,
}, },
"type": "unsupported_unit_state", "type": "units_changed",
}, }
],
}
await assert_validation_result(client, expected)
# Valid state - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state in compatible unit - empty response
hass.states.async_set(
"sensor.test", 13, attributes={**attributes, **{"unit_of_measurement": unit2}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Remove the state - empty response
hass.states.async_remove("sensor.test")
expected = {
"sensor.test": [
{
"data": {"statistic_id": "sensor.test"},
"type": "no_state",
}
], ],
} }
await assert_validation_result(client, expected) await assert_validation_result(client, expected)
@ -3473,7 +3642,7 @@ async def test_validate_statistics_sensor_removed(
"attributes", "attributes",
[BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES], [BATTERY_SENSOR_ATTRIBUTES, NONE_SENSOR_ATTRIBUTES],
) )
async def test_validate_statistics_unsupported_device_class( async def test_validate_statistics_unit_change_no_conversion(
hass, recorder_mock, hass_ws_client, attributes hass, recorder_mock, hass_ws_client, attributes
): ):
"""Test validate_statistics.""" """Test validate_statistics."""
@ -3553,6 +3722,7 @@ async def test_validate_statistics_unsupported_device_class(
"metadata_unit": "dogs", "metadata_unit": "dogs",
"state_unit": attributes.get("unit_of_measurement"), "state_unit": attributes.get("unit_of_measurement"),
"statistic_id": "sensor.test", "statistic_id": "sensor.test",
"supported_unit": "dogs",
}, },
"type": "units_changed", "type": "units_changed",
} }
@ -3573,124 +3743,7 @@ async def test_validate_statistics_unsupported_device_class(
await async_recorder_block_till_done(hass) await async_recorder_block_till_done(hass)
await assert_validation_result(client, {}) await assert_validation_result(client, {})
# Remove the state - empty response # Remove the state - expect error
hass.states.async_remove("sensor.test")
expected = {
"sensor.test": [
{
"data": {"statistic_id": "sensor.test"},
"type": "no_state",
}
],
}
await assert_validation_result(client, expected)
@pytest.mark.parametrize(
"attributes",
[KW_SENSOR_ATTRIBUTES],
)
async def test_validate_statistics_unsupported_device_class_2(
hass, recorder_mock, hass_ws_client, attributes
):
"""Test validate_statistics."""
id = 1
def next_id():
nonlocal id
id += 1
return id
async def assert_validation_result(client, expected_result):
await client.send_json(
{"id": next_id(), "type": "recorder/validate_statistics"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == expected_result
async def assert_statistic_ids(expected_result):
with session_scope(hass=hass) as session:
db_states = list(session.query(StatisticsMeta))
assert len(db_states) == len(expected_result)
for i in range(len(db_states)):
assert db_states[i].statistic_id == expected_result[i]["statistic_id"]
assert (
db_states[i].unit_of_measurement
== expected_result[i]["unit_of_measurement"]
)
now = dt_util.utcnow()
await async_setup_component(hass, "sensor", {})
await async_recorder_block_till_done(hass)
client = await hass_ws_client()
# No statistics, no state - empty response
await assert_validation_result(client, {})
# No statistics, original unit - empty response
hass.states.async_set("sensor.test", 10, attributes=attributes)
await assert_validation_result(client, {})
# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 11, attributes={**attributes, **{"unit_of_measurement": "W"}}
)
await assert_validation_result(client, {})
# Run statistics, no statistics will be generated because of conflicting units
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_statistic_ids([])
# No statistics, changed unit - empty response
hass.states.async_set(
"sensor.test", 12, attributes={**attributes, **{"unit_of_measurement": "W"}}
)
await assert_validation_result(client, {})
# Run statistics one hour later, only the "W" state will be considered
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now + timedelta(hours=1))
await async_recorder_block_till_done(hass)
await assert_statistic_ids(
[{"statistic_id": "sensor.test", "unit_of_measurement": "W"}]
)
await assert_validation_result(client, {})
# Change back to original unit - expect error
hass.states.async_set("sensor.test", 13, attributes=attributes)
await async_recorder_block_till_done(hass)
expected = {
"sensor.test": [
{
"data": {
"metadata_unit": "W",
"state_unit": "kW",
"statistic_id": "sensor.test",
},
"type": "units_changed_can_convert",
}
],
}
await assert_validation_result(client, expected)
# Changed unit - empty response
hass.states.async_set(
"sensor.test", 14, attributes={**attributes, **{"unit_of_measurement": "W"}}
)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Valid state, statistic runs again - empty response
await async_recorder_block_till_done(hass)
do_adhoc_statistics(hass, start=now)
await async_recorder_block_till_done(hass)
await assert_validation_result(client, {})
# Remove the state - empty response
hass.states.async_remove("sensor.test") hass.states.async_remove("sensor.test")
expected = { expected = {
"sensor.test": [ "sensor.test": [

View File

@ -46,6 +46,13 @@ def disable_platform_only():
yield yield
@pytest.fixture(autouse=True)
def reduce_reconnect_timeout():
"""Reduces reconnect timeout to speed up tests."""
with patch("homeassistant.components.zha.config_flow.CONNECT_DELAY_S", 0.01):
yield
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_app(): def mock_app():
"""Mock zigpy app interface.""" """Mock zigpy app interface."""
@ -230,10 +237,10 @@ async def test_efr32_via_zeroconf(hass):
await hass.async_block_till_done() await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == "socket://192.168.1.200:6638" assert result3["title"] == "socket://192.168.1.200:1234"
assert result3["data"] == { assert result3["data"] == {
CONF_DEVICE: { CONF_DEVICE: {
CONF_DEVICE_PATH: "socket://192.168.1.200:6638", CONF_DEVICE_PATH: "socket://192.168.1.200:1234",
CONF_BAUDRATE: 115200, CONF_BAUDRATE: 115200,
CONF_FLOWCONTROL: "software", CONF_FLOWCONTROL: "software",
}, },
@ -1476,21 +1483,28 @@ async def test_options_flow_defaults(async_setup_entry, async_unload_effect, has
# Unload it ourselves # Unload it ourselves
entry.state = config_entries.ConfigEntryState.NOT_LOADED entry.state = config_entries.ConfigEntryState.NOT_LOADED
# Reconfigure ZHA
assert result1["step_id"] == "prompt_migrate_or_reconfigure"
result2 = await hass.config_entries.options.async_configure(
flow["flow_id"],
user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE},
)
# Current path is the default # Current path is the default
assert result1["step_id"] == "choose_serial_port" assert result2["step_id"] == "choose_serial_port"
assert "/dev/ttyUSB0" in result1["data_schema"]({})[CONF_DEVICE_PATH] assert "/dev/ttyUSB0" in result2["data_schema"]({})[CONF_DEVICE_PATH]
# Autoprobing fails, we have to manually choose the radio type # Autoprobing fails, we have to manually choose the radio type
result2 = await hass.config_entries.options.async_configure( result3 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
) )
# Current radio type is the default # Current radio type is the default
assert result2["step_id"] == "manual_pick_radio_type" assert result3["step_id"] == "manual_pick_radio_type"
assert result2["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description assert result3["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description
# Continue on to port settings # Continue on to port settings
result3 = await hass.config_entries.options.async_configure( result4 = await hass.config_entries.options.async_configure(
flow["flow_id"], flow["flow_id"],
user_input={ user_input={
CONF_RADIO_TYPE: RadioType.znp.description, CONF_RADIO_TYPE: RadioType.znp.description,
@ -1498,12 +1512,12 @@ async def test_options_flow_defaults(async_setup_entry, async_unload_effect, has
) )
# The defaults match our current settings # The defaults match our current settings
assert result3["step_id"] == "manual_port_config" assert result4["step_id"] == "manual_port_config"
assert result3["data_schema"]({}) == entry.data[CONF_DEVICE] assert result4["data_schema"]({}) == entry.data[CONF_DEVICE]
with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)):
# Change the serial port path # Change the serial port path
result4 = await hass.config_entries.options.async_configure( result5 = await hass.config_entries.options.async_configure(
flow["flow_id"], flow["flow_id"],
user_input={ user_input={
# Change everything # Change everything
@ -1514,18 +1528,18 @@ async def test_options_flow_defaults(async_setup_entry, async_unload_effect, has
) )
# The radio has been detected, we can move on to creating the config entry # The radio has been detected, we can move on to creating the config entry
assert result4["step_id"] == "choose_formation_strategy" assert result5["step_id"] == "choose_formation_strategy"
async_setup_entry.assert_not_called() async_setup_entry.assert_not_called()
result5 = await hass.config_entries.options.async_configure( result6 = await hass.config_entries.options.async_configure(
result1["flow_id"], result1["flow_id"],
user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS}, user_input={"next_step_id": config_flow.FORMATION_REUSE_SETTINGS},
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert result5["type"] == FlowResultType.CREATE_ENTRY assert result6["type"] == FlowResultType.CREATE_ENTRY
assert result5["data"] == {} assert result6["data"] == {}
# The updated entry contains correct settings # The updated entry contains correct settings
assert entry.data == { assert entry.data == {
@ -1581,33 +1595,39 @@ async def test_options_flow_defaults_socket(hass):
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
) )
# Radio path must be manually entered assert result1["step_id"] == "prompt_migrate_or_reconfigure"
assert result1["step_id"] == "choose_serial_port"
assert result1["data_schema"]({})[CONF_DEVICE_PATH] == config_flow.CONF_MANUAL_PATH
result2 = await hass.config_entries.options.async_configure( result2 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={} flow["flow_id"],
user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE},
) )
# Current radio type is the default # Radio path must be manually entered
assert result2["step_id"] == "manual_pick_radio_type" assert result2["step_id"] == "choose_serial_port"
assert result2["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description assert result2["data_schema"]({})[CONF_DEVICE_PATH] == config_flow.CONF_MANUAL_PATH
# Continue on to port settings
result3 = await hass.config_entries.options.async_configure( result3 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
) )
# Current radio type is the default
assert result3["step_id"] == "manual_pick_radio_type"
assert result3["data_schema"]({})[CONF_RADIO_TYPE] == RadioType.znp.description
# Continue on to port settings
result4 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={}
)
# The defaults match our current settings # The defaults match our current settings
assert result3["step_id"] == "manual_port_config" assert result4["step_id"] == "manual_port_config"
assert result3["data_schema"]({}) == entry.data[CONF_DEVICE] assert result4["data_schema"]({}) == entry.data[CONF_DEVICE]
with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)): with patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)):
result4 = await hass.config_entries.options.async_configure( result5 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
) )
assert result4["step_id"] == "choose_formation_strategy" assert result5["step_id"] == "choose_formation_strategy"
@patch("homeassistant.components.zha.async_setup_entry", return_value=True) @patch("homeassistant.components.zha.async_setup_entry", return_value=True)
@ -1643,14 +1663,82 @@ async def test_options_flow_restarts_running_zha_if_cancelled(async_setup_entry,
entry.state = config_entries.ConfigEntryState.NOT_LOADED entry.state = config_entries.ConfigEntryState.NOT_LOADED
assert result1["step_id"] == "prompt_migrate_or_reconfigure"
result2 = await hass.config_entries.options.async_configure(
flow["flow_id"],
user_input={"next_step_id": config_flow.OPTIONS_INTENT_RECONFIGURE},
)
# Radio path must be manually entered # Radio path must be manually entered
assert result1["step_id"] == "choose_serial_port" assert result2["step_id"] == "choose_serial_port"
async_setup_entry.reset_mock() async_setup_entry.reset_mock()
# Abort the flow # Abort the flow
hass.config_entries.options.async_abort(result1["flow_id"]) hass.config_entries.options.async_abort(result2["flow_id"])
await hass.async_block_till_done() await hass.async_block_till_done()
# ZHA was set up once more # ZHA was set up once more
async_setup_entry.assert_called_once_with(hass, entry) async_setup_entry.assert_called_once_with(hass, entry)
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
async def test_options_flow_migration_reset_old_adapter(hass, mock_app):
"""Test options flow for migrating from an old radio."""
entry = MockConfigEntry(
version=config_flow.ZhaConfigFlowHandler.VERSION,
domain=DOMAIN,
data={
CONF_DEVICE: {
CONF_DEVICE_PATH: "/dev/serial/by-id/old_radio",
CONF_BAUDRATE: 12345,
CONF_FLOWCONTROL: None,
},
CONF_RADIO_TYPE: "znp",
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
flow = await hass.config_entries.options.async_init(entry.entry_id)
# ZHA gets unloaded
with patch(
"homeassistant.config_entries.ConfigEntries.async_unload", return_value=True
):
result1 = await hass.config_entries.options.async_configure(
flow["flow_id"], user_input={}
)
entry.state = config_entries.ConfigEntryState.NOT_LOADED
assert result1["step_id"] == "prompt_migrate_or_reconfigure"
result2 = await hass.config_entries.options.async_configure(
flow["flow_id"],
user_input={"next_step_id": config_flow.OPTIONS_INTENT_MIGRATE},
)
# User must explicitly approve radio reset
assert result2["step_id"] == "intent_migrate"
mock_app.reset_network_info = AsyncMock()
result3 = await hass.config_entries.options.async_configure(
flow["flow_id"],
user_input={},
)
mock_app.reset_network_info.assert_awaited_once()
# Now we can unplug the old radio
assert result3["step_id"] == "instruct_unplug"
# And move on to choosing the new radio
result4 = await hass.config_entries.options.async_configure(
flow["flow_id"],
user_input={},
)
assert result4["step_id"] == "choose_serial_port"