Compare commits

...

15 Commits

Author SHA1 Message Date
Franck Nijhof 35a3d2306c Bump version to 2024.8.0b2 2024-08-05 12:22:03 +02:00
Calvin Walton cdb378066c Add Govee H612B to the Matter transition blocklist (#123163) 2024-08-05 12:21:40 +02:00
Brett Adams 85700fd80f Fix class attribute condition in Tesla Fleet (#123162) 2024-08-05 12:21:37 +02:00
J. Nick Koston 73a2ad7304 Bump aiohttp to 3.10.1 (#123159) 2024-08-05 12:21:34 +02:00
dupondje f6c4b6b045 dsmr: migrate hourly_gas_meter_reading to mbus device (#123149) 2024-08-05 12:21:30 +02:00
Steve Repsher 0b4d921762 Restore old service worker URL (#123131) 2024-08-05 12:21:27 +02:00
David F. Mulcahey c8a0e5228d Bump ZHA lib to 0.0.27 (#123125) 2024-08-05 12:21:23 +02:00
Kim de Vos 832bac8c63 Use slugify to create id for UniFi WAN latency (#123108)
Use slugify to create id for latency
2024-08-05 12:21:20 +02:00
Arie Catsman eccce7017f Bump pyenphase to 1.22.0 (#123103) 2024-08-05 12:21:16 +02:00
Louis Christ fdb1baadbe Fix wrong DeviceInfo in bluesound integration (#123101)
Fix bluesound device info
2024-08-05 12:21:12 +02:00
Shay Levy 7623ee49e4 Ignore Shelly IPv6 address in zeroconf (#123081) 2024-08-05 12:20:20 +02:00
Mr. Bubbles fa241dcd04 Catch exception in coordinator setup of IronOS integration (#123079) 2024-08-05 12:20:17 +02:00
Denis Shulyaka bee77041e8 Change enum type to string for Google Generative AI Conversation (#123069) 2024-08-05 12:20:13 +02:00
Paulus Schoutsen 50b7eb44d1 Add CONTROL supported feature to Google conversation when API access (#123046)
* Add CONTROL supported feature to Google conversation when API access

* Better function name

* Handle entry update inline

* Reload instead of update
2024-08-05 12:20:10 +02:00
Clifford Roche 7b1bf82e3c Update greeclimate to 2.0.0 (#121030)
Co-authored-by: Joostlek <joostlek@outlook.com>
2024-08-05 12:20:01 +02:00
28 changed files with 467 additions and 137 deletions
@@ -255,7 +255,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
if port is DEFAULT_PORT:
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
+3 -1
View File
@@ -439,7 +439,9 @@ def rename_old_gas_to_mbus(
entries = er.async_entries_for_device(ent_reg, device_id)
for entity in entries:
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
if entity.unique_id.endswith(
"belgium_5min_gas_meter_reading"
) or entity.unique_id.endswith("hourly_gas_meter_reading"):
try:
ent_reg.async_update_entity(
entity.entity_id,
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"requirements": ["pyenphase==1.20.6"],
"requirements": ["pyenphase==1.22.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
@@ -398,6 +398,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
static_paths_configs: list[StaticPathConfig] = []
for path, should_cache in (
("service_worker.js", False),
("sw-modern.js", False),
("sw-modern.js.map", False),
("sw-legacy.js", False),
@@ -89,9 +89,9 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
key = "type_"
val = val.upper()
elif key == "format":
if (schema.get("type") == "string" and val != "enum") or (
schema.get("type") not in ("number", "integer", "string")
):
if schema.get("type") == "string" and val != "enum":
continue
if schema.get("type") not in ("number", "integer", "string"):
continue
key = "format_"
elif key == "items":
@@ -100,11 +100,19 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
if result.get("enum") and result.get("type_") != "STRING":
# enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
result["type_"] = "STRING"
result["enum"] = [str(item) for item in result["enum"]]
if result.get("type_") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type_": "STRING"}}
result["required"] = []
return result
@@ -164,6 +172,10 @@ class GoogleGenerativeAIConversationEntity(
model="Generative AI",
entry_type=dr.DeviceEntryType.SERVICE,
)
if self.entry.options.get(CONF_LLM_HASS_API):
self._attr_supported_features = (
conversation.ConversationEntityFeature.CONTROL
)
@property
def supported_languages(self) -> list[str] | Literal["*"]:
@@ -177,6 +189,9 @@ class GoogleGenerativeAIConversationEntity(
self.hass, "conversation", self.entry.entry_id, self.entity_id
)
conversation.async_set_agent(self.hass, self.entry, self)
self.entry.async_on_unload(
self.entry.add_update_listener(self._async_entry_update_listener)
)
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from Home Assistant."""
@@ -397,3 +412,10 @@ class GoogleGenerativeAIConversationEntity(
parts.append(llm_api.api_prompt)
return "\n".join(parts)
async def _async_entry_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Handle options update."""
# Reload as we update device info + entity name + supported features
await hass.config_entries.async_reload(entry.entry_id)
+2
View File
@@ -18,3 +18,5 @@ FAN_MEDIUM_HIGH = "medium high"
MAX_ERRORS = 2
TARGET_TEMPERATURE_STEP = 1
UPDATE_INTERVAL = 60
+55 -10
View File
@@ -2,16 +2,20 @@
from __future__ import annotations
from datetime import timedelta
from datetime import datetime, timedelta
import logging
from typing import Any
from greeclimate.device import Device, DeviceInfo
from greeclimate.discovery import Discovery, Listener
from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError
from greeclimate.network import Response
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.dt import utcnow
from .const import (
COORDINATORS,
@@ -19,12 +23,13 @@ from .const import (
DISPATCH_DEVICE_DISCOVERED,
DOMAIN,
MAX_ERRORS,
UPDATE_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
class DeviceDataUpdateCoordinator(DataUpdateCoordinator):
class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages polling for state changes from the device."""
def __init__(self, hass: HomeAssistant, device: Device) -> None:
@@ -34,28 +39,68 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator):
hass,
_LOGGER,
name=f"{DOMAIN}-{device.device_info.name}",
update_interval=timedelta(seconds=60),
update_interval=timedelta(seconds=UPDATE_INTERVAL),
always_update=False,
)
self.device = device
self._error_count = 0
self.device.add_handler(Response.DATA, self.device_state_updated)
self.device.add_handler(Response.RESULT, self.device_state_updated)
async def _async_update_data(self):
self._error_count: int = 0
self._last_response_time: datetime = utcnow()
self._last_error_time: datetime | None = None
def device_state_updated(self, *args: Any) -> None:
"""Handle device state updates."""
_LOGGER.debug("Device state updated: %s", json_dumps(args))
self._error_count = 0
self._last_response_time = utcnow()
self.async_set_updated_data(self.device.raw_properties)
async def _async_update_data(self) -> dict[str, Any]:
"""Update the state of the device."""
_LOGGER.debug(
"Updating device state: %s, error count: %d", self.name, self._error_count
)
try:
await self.device.update_state()
except DeviceNotBoundError as error:
raise UpdateFailed(f"Device {self.name} is unavailable") from error
raise UpdateFailed(
f"Device {self.name} is unavailable, device is not bound."
) from error
except DeviceTimeoutError as error:
self._error_count += 1
# Under normal conditions GREE units timeout every once in a while
if self.last_update_success and self._error_count >= MAX_ERRORS:
_LOGGER.warning(
"Device is unavailable: %s (%s)",
self.name,
self.device.device_info,
"Device %s is unavailable: %s", self.name, self.device.device_info
)
raise UpdateFailed(f"Device {self.name} is unavailable") from error
raise UpdateFailed(
f"Device {self.name} is unavailable, could not send update request"
) from error
else:
# raise update failed if time for more than MAX_ERRORS has passed since last update
now = utcnow()
elapsed_success = now - self._last_response_time
if self.update_interval and elapsed_success >= self.update_interval:
if not self._last_error_time or (
(now - self.update_interval) >= self._last_error_time
):
self._last_error_time = now
self._error_count += 1
_LOGGER.warning(
"Device %s is unresponsive for %s seconds",
self.name,
elapsed_success,
)
if self.last_update_success and self._error_count >= MAX_ERRORS:
raise UpdateFailed(
f"Device {self.name} is unresponsive for too long and now unavailable"
)
return self.device.raw_properties
async def push_state_update(self):
"""Send state updates to the physical device."""
+1 -1
View File
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/gree",
"iot_class": "local_polling",
"loggers": ["greeclimate"],
"requirements": ["greeclimate==1.4.6"]
"requirements": ["greeclimate==2.0.0"]
}
@@ -46,4 +46,8 @@ class IronOSCoordinator(DataUpdateCoordinator[LiveDataResponse]):
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self.device_info = await self.device.get_device_info()
try:
self.device_info = await self.device.get_device_info()
except CommunicationError as e:
raise UpdateFailed("Cannot connect to device") from e
+1
View File
@@ -54,6 +54,7 @@ TRANSITION_BLOCKLIST = (
(4488, 514, "1.0", "1.0.0"),
(4488, 260, "1.0", "1.0.0"),
(5010, 769, "3.0", "1.0.0"),
(4999, 24875, "1.0", "27.0"),
(4999, 25057, "1.0", "27.0"),
(4448, 36866, "V1", "V1.0.0.5"),
(5009, 514, "1.0", "1.0.0"),
@@ -279,6 +279,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
if discovery_info.ip_address.version == 6:
return self.async_abort(reason="ipv6_not_supported")
host = discovery_info.host
# First try to get the mac address from the name
# so we can avoid making another connection to the
+2 -1
View File
@@ -52,7 +52,8 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used."
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.",
"ipv6_not_supported": "IPv6 is not supported."
}
},
"device_automation": {
@@ -86,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
vehicles: list[TeslaFleetVehicleData] = []
energysites: list[TeslaFleetEnergyData] = []
for product in products:
if "vin" in product and tesla.vehicle:
if "vin" in product and hasattr(tesla, "vehicle"):
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
@@ -111,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
device=device,
)
)
elif "energy_site_id" in product and tesla.energy:
elif "energy_site_id" in product and hasattr(tesla, "energy"):
site_id = product["energy_site_id"]
if not (
product["components"]["battery"]
+5 -4
View File
@@ -44,6 +44,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import slugify
import homeassistant.util.dt as dt_util
from . import UnifiConfigEntry
@@ -247,8 +248,9 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]:
def make_wan_latency_entity_description(
wan: Literal["WAN", "WAN2"], name: str, monitor_target: str
) -> UnifiSensorEntityDescription:
name_wan = f"{name} {wan}"
return UnifiSensorEntityDescription[Devices, Device](
key=f"{name} {wan} latency",
key=f"{name_wan} latency",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
state_class=SensorStateClass.MEASUREMENT,
@@ -257,13 +259,12 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]:
api_handler_fn=lambda api: api.devices,
available_fn=async_device_available_fn,
device_info_fn=async_device_device_info_fn,
name_fn=lambda _: f"{name} {wan} latency",
name_fn=lambda device: f"{name_wan} latency",
object_fn=lambda api, obj_id: api.devices[obj_id],
supported_fn=partial(
async_device_wan_latency_supported_fn, wan, monitor_target
),
unique_id_fn=lambda hub,
obj_id: f"{name.lower}_{wan.lower}_latency-{obj_id}",
unique_id_fn=lambda hub, obj_id: f"{slugify(name_wan)}_latency-{obj_id}",
value_fn=partial(async_device_wan_latency_value_fn, wan, monitor_target),
)
+1 -1
View File
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
"requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.25"],
"requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.27"],
"usb": [
{
"vid": "10C4",
+1 -1
View File
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 8
PATCH_VERSION: Final = "0b1"
PATCH_VERSION: Final = "0b2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
+1 -1
View File
@@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.2
aiodiscover==2.1.0
aiodns==3.2.0
aiohttp-fast-zlib==0.1.1
aiohttp==3.10.0
aiohttp==3.10.1
aiohttp_cors==0.7.0
aiozoneinfo==0.2.1
astral==2.2
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2024.8.0b1"
version = "2024.8.0b2"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@@ -24,7 +24,7 @@ classifiers = [
requires-python = ">=3.12.0"
dependencies = [
"aiodns==3.2.0",
"aiohttp==3.10.0",
"aiohttp==3.10.1",
"aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.1.1",
"aiozoneinfo==0.2.1",
+1 -1
View File
@@ -4,7 +4,7 @@
# Home Assistant Core
aiodns==3.2.0
aiohttp==3.10.0
aiohttp==3.10.1
aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.1.1
aiozoneinfo==0.2.1
+3 -3
View File
@@ -1007,7 +1007,7 @@ gpiozero==1.6.2
gps3==0.33.3
# homeassistant.components.gree
greeclimate==1.4.6
greeclimate==2.0.0
# homeassistant.components.greeneye_monitor
greeneye_monitor==3.0.3
@@ -1840,7 +1840,7 @@ pyeiscp==0.0.7
pyemoncms==0.0.7
# homeassistant.components.enphase_envoy
pyenphase==1.20.6
pyenphase==1.22.0
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -2986,7 +2986,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
zha==0.0.25
zha==0.0.27
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.12
+3 -3
View File
@@ -848,7 +848,7 @@ govee-local-api==1.5.1
gps3==0.33.3
# homeassistant.components.gree
greeclimate==1.4.6
greeclimate==2.0.0
# homeassistant.components.greeneye_monitor
greeneye_monitor==3.0.3
@@ -1469,7 +1469,7 @@ pyegps==0.2.5
pyemoncms==0.0.7
# homeassistant.components.enphase_envoy
pyenphase==1.20.6
pyenphase==1.22.0
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -2360,7 +2360,7 @@ zeroconf==0.132.2
zeversolar==0.3.1
# homeassistant.components.zha
zha==0.0.25
zha==0.0.27
# homeassistant.components.zwave_js
zwave-js-server-python==0.57.0
@@ -119,6 +119,106 @@ async def test_migrate_gas_to_mbus(
)
async def test_migrate_hourly_gas_to_mbus(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock],
) -> None:
"""Test migration of unique_id."""
(connection_factory, transport, protocol) = dsmr_connection_fixture
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="/dev/ttyUSB0",
data={
"port": "/dev/ttyUSB0",
"dsmr_version": "5",
"serial_id": "1234",
"serial_id_gas": "4730303738353635363037343639323231",
},
options={
"time_between_update": 0,
},
)
mock_entry.add_to_hass(hass)
old_unique_id = "4730303738353635363037343639323231_hourly_gas_meter_reading"
device = device_registry.async_get_or_create(
config_entry_id=mock_entry.entry_id,
identifiers={(DOMAIN, mock_entry.entry_id)},
name="Gas Meter",
)
await hass.async_block_till_done()
entity: er.RegistryEntry = entity_registry.async_get_or_create(
suggested_object_id="gas_meter_reading",
disabled_by=None,
domain=SENSOR_DOMAIN,
platform=DOMAIN,
device_id=device.id,
unique_id=old_unique_id,
config_entry=mock_entry,
)
assert entity.unique_id == old_unique_id
await hass.async_block_till_done()
telegram = Telegram()
telegram.add(
MBUS_DEVICE_TYPE,
CosemObject((0, 1), [{"value": "003", "unit": ""}]),
"MBUS_DEVICE_TYPE",
)
telegram.add(
MBUS_EQUIPMENT_IDENTIFIER,
CosemObject(
(0, 1),
[{"value": "4730303738353635363037343639323231", "unit": ""}],
),
"MBUS_EQUIPMENT_IDENTIFIER",
)
telegram.add(
MBUS_METER_READING,
MBusObject(
(0, 1),
[
{"value": datetime.datetime.fromtimestamp(1722749707)},
{"value": Decimal(778.963), "unit": "m3"},
],
),
"MBUS_METER_READING",
)
assert await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
telegram_callback = connection_factory.call_args_list[0][0][2]
# simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
telegram_callback(telegram)
# after receiving telegram entities need to have the chance to be created
await hass.async_block_till_done()
dev_entities = er.async_entries_for_device(
entity_registry, device.id, include_disabled_entities=True
)
assert not dev_entities
assert (
entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id)
is None
)
assert (
entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, "4730303738353635363037343639323231"
)
== "sensor.gas_meter_reading"
)
async def test_migrate_gas_to_mbus_exists(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@@ -215,7 +215,7 @@
),
])
# ---
# name: test_default_prompt[config_entry_options0-None]
# name: test_default_prompt[config_entry_options0-0-None]
list([
tuple(
'',
@@ -263,7 +263,7 @@
),
])
# ---
# name: test_default_prompt[config_entry_options0-conversation.google_generative_ai_conversation]
# name: test_default_prompt[config_entry_options0-0-conversation.google_generative_ai_conversation]
list([
tuple(
'',
@@ -311,7 +311,7 @@
),
])
# ---
# name: test_default_prompt[config_entry_options1-None]
# name: test_default_prompt[config_entry_options1-1-None]
list([
tuple(
'',
@@ -360,7 +360,7 @@
),
])
# ---
# name: test_default_prompt[config_entry_options1-conversation.google_generative_ai_conversation]
# name: test_default_prompt[config_entry_options1-1-conversation.google_generative_ai_conversation]
list([
tuple(
'',
@@ -17,8 +17,9 @@ from homeassistant.components.google_generative_ai_conversation.const import (
)
from homeassistant.components.google_generative_ai_conversation.conversation import (
_escape_decode,
_format_schema,
)
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent, llm
@@ -38,10 +39,13 @@ def freeze_the_time():
"agent_id", [None, "conversation.google_generative_ai_conversation"]
)
@pytest.mark.parametrize(
"config_entry_options",
("config_entry_options", "expected_features"),
[
{},
{CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
({}, 0),
(
{CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
conversation.ConversationEntityFeature.CONTROL,
),
],
)
@pytest.mark.usefixtures("mock_init_component")
@@ -51,6 +55,7 @@ async def test_default_prompt(
snapshot: SnapshotAssertion,
agent_id: str | None,
config_entry_options: {},
expected_features: conversation.ConversationEntityFeature,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that the default prompt works."""
@@ -97,6 +102,9 @@ async def test_default_prompt(
assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot
assert mock_get_tools.called == (CONF_LLM_HASS_API in config_entry_options)
state = hass.states.get("conversation.google_generative_ai_conversation")
assert state.attributes[ATTR_SUPPORTED_FEATURES] == expected_features
@pytest.mark.parametrize(
("model_name", "supports_system_instruction"),
@@ -622,3 +630,61 @@ async def test_escape_decode() -> None:
"param2": "param2's value",
"param3": {"param31": "Cheminée", "param32": "Cheminée"},
}
@pytest.mark.parametrize(
("openapi", "protobuf"),
[
(
{"type": "string", "enum": ["a", "b", "c"]},
{"type_": "STRING", "enum": ["a", "b", "c"]},
),
(
{"type": "integer", "enum": [1, 2, 3]},
{"type_": "STRING", "enum": ["1", "2", "3"]},
),
({"anyOf": [{"type": "integer"}, {"type": "number"}]}, {"type_": "INTEGER"}),
(
{
"anyOf": [
{"anyOf": [{"type": "integer"}, {"type": "number"}]},
{"anyOf": [{"type": "integer"}, {"type": "number"}]},
]
},
{"type_": "INTEGER"},
),
({"type": "string", "format": "lower"}, {"type_": "STRING"}),
({"type": "boolean", "format": "bool"}, {"type_": "BOOLEAN"}),
(
{"type": "number", "format": "percent"},
{"type_": "NUMBER", "format_": "percent"},
),
(
{
"type": "object",
"properties": {"var": {"type": "string"}},
"required": [],
},
{
"type_": "OBJECT",
"properties": {"var": {"type_": "STRING"}},
"required": [],
},
),
(
{"type": "object", "additionalProperties": True},
{
"type_": "OBJECT",
"properties": {"json": {"type_": "STRING"}},
"required": [],
},
),
(
{"type": "array", "items": {"type": "string"}},
{"type_": "ARRAY", "items": {"type_": "STRING"}},
),
],
)
async def test_format_schema(openapi, protobuf) -> None:
"""Test _format_schema."""
assert _format_schema(openapi) == protobuf
+33 -2
View File
@@ -5,8 +5,12 @@ from datetime import timedelta
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.climate import DOMAIN
from homeassistant.components.gree.const import COORDINATORS, DOMAIN as GREE
from homeassistant.components.climate import DOMAIN, HVACMode
from homeassistant.components.gree.const import (
COORDINATORS,
DOMAIN as GREE,
UPDATE_INTERVAL,
)
from homeassistant.core import HomeAssistant
import homeassistant.util.dt as dt_util
@@ -69,3 +73,30 @@ async def test_discovery_after_setup(
device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]]
assert device_infos[0].ip == "1.1.1.2"
assert device_infos[1].ip == "2.2.2.1"
async def test_coordinator_updates(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Test gree devices update their state."""
await async_setup_gree(hass)
await hass.async_block_till_done()
assert len(hass.states.async_all(DOMAIN)) == 1
callback = device().add_handler.call_args_list[0][0][1]
async def fake_update_state(*args) -> None:
"""Fake update state."""
device().power = True
callback()
device().update_state.side_effect = fake_update_state
freezer.tick(timedelta(seconds=UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID_1)
assert state is not None
assert state.state != HVACMode.OFF
+97 -90
View File
@@ -48,7 +48,12 @@ from homeassistant.components.gree.climate import (
HVAC_MODES_REVERSE,
GreeClimateEntity,
)
from homeassistant.components.gree.const import FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW
from homeassistant.components.gree.const import (
DISCOVERY_SCAN_INTERVAL,
FAN_MEDIUM_HIGH,
FAN_MEDIUM_LOW,
UPDATE_INTERVAL,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
@@ -61,7 +66,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
import homeassistant.util.dt as dt_util
from .common import async_setup_gree, build_device_mock
@@ -70,12 +74,6 @@ from tests.common import async_fire_time_changed
ENTITY_ID = f"{DOMAIN}.fake_device_1"
@pytest.fixture
def mock_now():
"""Fixture for dtutil.now."""
return dt_util.utcnow()
async def test_discovery_called_once(hass: HomeAssistant, discovery, device) -> None:
"""Test discovery is only ever called once."""
await async_setup_gree(hass)
@@ -104,7 +102,7 @@ async def test_discovery_setup(hass: HomeAssistant, discovery, device) -> None:
async def test_discovery_setup_connection_error(
hass: HomeAssistant, discovery, device, mock_now
hass: HomeAssistant, discovery, device
) -> None:
"""Test gree integration is setup."""
MockDevice1 = build_device_mock(
@@ -126,7 +124,7 @@ async def test_discovery_setup_connection_error(
async def test_discovery_after_setup(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Test gree devices don't change after multiple discoveries."""
MockDevice1 = build_device_mock(
@@ -142,8 +140,7 @@ async def test_discovery_after_setup(
discovery.return_value.mock_devices = [MockDevice1, MockDevice2]
device.side_effect = [MockDevice1, MockDevice2]
await async_setup_gree(hass)
await hass.async_block_till_done()
await async_setup_gree(hass) # Update 1
assert discovery.return_value.scan_count == 1
assert len(hass.states.async_all(DOMAIN)) == 2
@@ -152,9 +149,8 @@ async def test_discovery_after_setup(
discovery.return_value.mock_devices = [MockDevice1, MockDevice2]
device.side_effect = [MockDevice1, MockDevice2]
next_update = mock_now + timedelta(minutes=6)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
freezer.tick(timedelta(seconds=DISCOVERY_SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert discovery.return_value.scan_count == 2
@@ -162,7 +158,7 @@ async def test_discovery_after_setup(
async def test_discovery_add_device_after_setup(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Test gree devices can be added after initial setup."""
MockDevice1 = build_device_mock(
@@ -178,6 +174,8 @@ async def test_discovery_add_device_after_setup(
discovery.return_value.mock_devices = [MockDevice1]
device.side_effect = [MockDevice1]
await async_setup_gree(hass) # Update 1
await async_setup_gree(hass)
await hass.async_block_till_done()
@@ -188,9 +186,8 @@ async def test_discovery_add_device_after_setup(
discovery.return_value.mock_devices = [MockDevice2]
device.side_effect = [MockDevice2]
next_update = mock_now + timedelta(minutes=6)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
freezer.tick(timedelta(seconds=DISCOVERY_SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert discovery.return_value.scan_count == 2
@@ -198,7 +195,7 @@ async def test_discovery_add_device_after_setup(
async def test_discovery_device_bind_after_setup(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Test gree devices can be added after a late device bind."""
MockDevice1 = build_device_mock(
@@ -210,8 +207,7 @@ async def test_discovery_device_bind_after_setup(
discovery.return_value.mock_devices = [MockDevice1]
device.return_value = MockDevice1
await async_setup_gree(hass)
await hass.async_block_till_done()
await async_setup_gree(hass) # Update 1
assert len(hass.states.async_all(DOMAIN)) == 1
state = hass.states.get(ENTITY_ID)
@@ -222,9 +218,8 @@ async def test_discovery_device_bind_after_setup(
MockDevice1.bind.side_effect = None
MockDevice1.update_state.side_effect = None
next_update = mock_now + timedelta(minutes=5)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
freezer.tick(timedelta(seconds=DISCOVERY_SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
@@ -232,7 +227,7 @@ async def test_discovery_device_bind_after_setup(
async def test_update_connection_failure(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, device, mock_now
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Testing update hvac connection failure exception."""
device().update_state.side_effect = [
@@ -241,36 +236,32 @@ async def test_update_connection_failure(
DeviceTimeoutError,
]
await async_setup_gree(hass)
await async_setup_gree(hass) # Update 1
async def run_update():
freezer.tick(timedelta(seconds=UPDATE_INTERVAL))
async_fire_time_changed(hass)
next_update = mock_now + timedelta(minutes=5)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# First update to make the device available
# Update 2
await run_update()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
next_update = mock_now + timedelta(minutes=10)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# Update 3
await run_update()
next_update = mock_now + timedelta(minutes=15)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# Then two more update failures to make the device unavailable
# Update 4
await run_update()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state == STATE_UNAVAILABLE
async def test_update_connection_failure_recovery(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now
async def test_update_connection_send_failure_recovery(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Testing update hvac connection failure recovery."""
device().update_state.side_effect = [
@@ -279,31 +270,27 @@ async def test_update_connection_failure_recovery(
DEFAULT_MOCK,
]
await async_setup_gree(hass)
await async_setup_gree(hass) # Update 1
async def run_update():
freezer.tick(timedelta(seconds=UPDATE_INTERVAL))
async_fire_time_changed(hass)
# First update becomes unavailable
next_update = mock_now + timedelta(minutes=5)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
await run_update() # Update 2
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state == STATE_UNAVAILABLE
# Second update restores the connection
next_update = mock_now + timedelta(minutes=10)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
await run_update() # Update 3
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
async def test_update_unhandled_exception(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Testing update hvac connection unhandled response exception."""
device().update_state.side_effect = [DEFAULT_MOCK, Exception]
@@ -314,9 +301,8 @@ async def test_update_unhandled_exception(
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
next_update = mock_now + timedelta(minutes=10)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
freezer.tick(timedelta(seconds=UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
@@ -325,15 +311,13 @@ async def test_update_unhandled_exception(
async def test_send_command_device_timeout(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Test for sending power on command to the device with a device timeout."""
await async_setup_gree(hass)
# First update to make the device available
next_update = mock_now + timedelta(minutes=5)
freezer.move_to(next_update)
async_fire_time_changed(hass, next_update)
freezer.tick(timedelta(seconds=UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
@@ -355,7 +339,40 @@ async def test_send_command_device_timeout(
assert state.state != STATE_UNAVAILABLE
async def test_send_power_on(hass: HomeAssistant, discovery, device, mock_now) -> None:
async def test_unresponsive_device(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device
) -> None:
"""Test for unresponsive device."""
await async_setup_gree(hass)
async def run_update():
freezer.tick(timedelta(seconds=UPDATE_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Update 2
await run_update()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
# Update 3, 4, 5
await run_update()
await run_update()
await run_update()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state == STATE_UNAVAILABLE
# Receiving update from device will reset the state to available again
device().device_state_updated("test")
await run_update()
state = hass.states.get(ENTITY_ID)
assert state.name == "fake-device-1"
assert state.state != STATE_UNAVAILABLE
async def test_send_power_on(hass: HomeAssistant, discovery, device) -> None:
"""Test for sending power on command to the device."""
await async_setup_gree(hass)
@@ -372,7 +389,7 @@ async def test_send_power_on(hass: HomeAssistant, discovery, device, mock_now) -
async def test_send_power_off_device_timeout(
hass: HomeAssistant, discovery, device, mock_now
hass: HomeAssistant, discovery, device
) -> None:
"""Test for sending power off command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
@@ -543,9 +560,7 @@ async def test_update_target_temperature(
@pytest.mark.parametrize(
"preset", [PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE]
)
async def test_send_preset_mode(
hass: HomeAssistant, discovery, device, mock_now, preset
) -> None:
async def test_send_preset_mode(hass: HomeAssistant, discovery, device, preset) -> None:
"""Test for sending preset mode command to the device."""
await async_setup_gree(hass)
@@ -561,9 +576,7 @@ async def test_send_preset_mode(
assert state.attributes.get(ATTR_PRESET_MODE) == preset
async def test_send_invalid_preset_mode(
hass: HomeAssistant, discovery, device, mock_now
) -> None:
async def test_send_invalid_preset_mode(hass: HomeAssistant, discovery, device) -> None:
"""Test for sending preset mode command to the device."""
await async_setup_gree(hass)
@@ -584,7 +597,7 @@ async def test_send_invalid_preset_mode(
"preset", [PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE]
)
async def test_send_preset_mode_device_timeout(
hass: HomeAssistant, discovery, device, mock_now, preset
hass: HomeAssistant, discovery, device, preset
) -> None:
"""Test for sending preset mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
@@ -607,7 +620,7 @@ async def test_send_preset_mode_device_timeout(
"preset", [PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE]
)
async def test_update_preset_mode(
hass: HomeAssistant, discovery, device, mock_now, preset
hass: HomeAssistant, discovery, device, preset
) -> None:
"""Test for updating preset mode from the device."""
device().steady_heat = preset == PRESET_AWAY
@@ -634,7 +647,7 @@ async def test_update_preset_mode(
],
)
async def test_send_hvac_mode(
hass: HomeAssistant, discovery, device, mock_now, hvac_mode
hass: HomeAssistant, discovery, device, hvac_mode
) -> None:
"""Test for sending hvac mode command to the device."""
await async_setup_gree(hass)
@@ -656,7 +669,7 @@ async def test_send_hvac_mode(
[HVACMode.AUTO, HVACMode.COOL, HVACMode.DRY, HVACMode.FAN_ONLY, HVACMode.HEAT],
)
async def test_send_hvac_mode_device_timeout(
hass: HomeAssistant, discovery, device, mock_now, hvac_mode
hass: HomeAssistant, discovery, device, hvac_mode
) -> None:
"""Test for sending hvac mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
@@ -687,7 +700,7 @@ async def test_send_hvac_mode_device_timeout(
],
)
async def test_update_hvac_mode(
hass: HomeAssistant, discovery, device, mock_now, hvac_mode
hass: HomeAssistant, discovery, device, hvac_mode
) -> None:
"""Test for updating hvac mode from the device."""
device().power = hvac_mode != HVACMode.OFF
@@ -704,9 +717,7 @@ async def test_update_hvac_mode(
"fan_mode",
[FAN_AUTO, FAN_LOW, FAN_MEDIUM_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH],
)
async def test_send_fan_mode(
hass: HomeAssistant, discovery, device, mock_now, fan_mode
) -> None:
async def test_send_fan_mode(hass: HomeAssistant, discovery, device, fan_mode) -> None:
"""Test for sending fan mode command to the device."""
await async_setup_gree(hass)
@@ -722,9 +733,7 @@ async def test_send_fan_mode(
assert state.attributes.get(ATTR_FAN_MODE) == fan_mode
async def test_send_invalid_fan_mode(
hass: HomeAssistant, discovery, device, mock_now
) -> None:
async def test_send_invalid_fan_mode(hass: HomeAssistant, discovery, device) -> None:
"""Test for sending fan mode command to the device."""
await async_setup_gree(hass)
@@ -746,7 +755,7 @@ async def test_send_invalid_fan_mode(
[FAN_AUTO, FAN_LOW, FAN_MEDIUM_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH],
)
async def test_send_fan_mode_device_timeout(
hass: HomeAssistant, discovery, device, mock_now, fan_mode
hass: HomeAssistant, discovery, device, fan_mode
) -> None:
"""Test for sending fan mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
@@ -770,7 +779,7 @@ async def test_send_fan_mode_device_timeout(
[FAN_AUTO, FAN_LOW, FAN_MEDIUM_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH],
)
async def test_update_fan_mode(
hass: HomeAssistant, discovery, device, mock_now, fan_mode
hass: HomeAssistant, discovery, device, fan_mode
) -> None:
"""Test for updating fan mode from the device."""
device().fan_speed = FAN_MODES_REVERSE.get(fan_mode)
@@ -786,7 +795,7 @@ async def test_update_fan_mode(
"swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL]
)
async def test_send_swing_mode(
hass: HomeAssistant, discovery, device, mock_now, swing_mode
hass: HomeAssistant, discovery, device, swing_mode
) -> None:
"""Test for sending swing mode command to the device."""
await async_setup_gree(hass)
@@ -803,9 +812,7 @@ async def test_send_swing_mode(
assert state.attributes.get(ATTR_SWING_MODE) == swing_mode
async def test_send_invalid_swing_mode(
hass: HomeAssistant, discovery, device, mock_now
) -> None:
async def test_send_invalid_swing_mode(hass: HomeAssistant, discovery, device) -> None:
"""Test for sending swing mode command to the device."""
await async_setup_gree(hass)
@@ -826,7 +833,7 @@ async def test_send_invalid_swing_mode(
"swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL]
)
async def test_send_swing_mode_device_timeout(
hass: HomeAssistant, discovery, device, mock_now, swing_mode
hass: HomeAssistant, discovery, device, swing_mode
) -> None:
"""Test for sending swing mode command to the device with a device timeout."""
device().push_state_update.side_effect = DeviceTimeoutError
@@ -849,7 +856,7 @@ async def test_send_swing_mode_device_timeout(
"swing_mode", [SWING_OFF, SWING_BOTH, SWING_VERTICAL, SWING_HORIZONTAL]
)
async def test_update_swing_mode(
hass: HomeAssistant, discovery, device, mock_now, swing_mode
hass: HomeAssistant, discovery, device, swing_mode
) -> None:
"""Test for updating swing mode from the device."""
device().horizontal_swing = (
+26
View File
@@ -0,0 +1,26 @@
"""Test init of IronOS integration."""
from unittest.mock import AsyncMock
from pynecil import CommunicationError
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("ble_device")
async def test_setup_config_entry_not_ready(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_pynecil: AsyncMock,
) -> None:
"""Test config entry not ready."""
mock_pynecil.get_device_info.side_effect = CommunicationError
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@@ -1305,3 +1305,22 @@ async def test_reconfigure_with_exception(
)
assert result["errors"] == {"base": base_error}
async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None:
"""Test zeroconf discovery rejects ipv6."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf.ZeroconfServiceInfo(
ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"),
ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")],
hostname="mock_hostname",
name="shelly1pm-12345",
port=None,
properties={zeroconf.ATTR_PROPERTIES_ID: "shelly1pm-12345"},
type="mock_type",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "ipv6_not_supported"