forked from home-assistant/core
Compare commits
24 Commits
2023.2.0b0
...
2023.2.0b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6397cc5d04 | ||
|
|
b7311dc655 | ||
|
|
e20c7491c1 | ||
|
|
8cbefd5f97 | ||
|
|
c7665b479a | ||
|
|
4f2966674a | ||
|
|
b464179eac | ||
|
|
cd59705c4b | ||
|
|
77bd23899f | ||
|
|
d211603ba7 | ||
|
|
1dc3bb6eb1 | ||
|
|
22afc7c7fb | ||
|
|
ba82f13821 | ||
|
|
41add96bab | ||
|
|
c8c3f4bef6 | ||
|
|
8cb8ecdae9 | ||
|
|
bd1371680f | ||
|
|
8f684e962a | ||
|
|
07a1259db9 | ||
|
|
ea2bf34647 | ||
|
|
a6fdf1d09a | ||
|
|
9ca04dbfa1 | ||
|
|
e1c8dff536 | ||
|
|
a1416b9044 |
@@ -128,7 +128,6 @@ SENSOR_DESCRIPTIONS = (
|
||||
key=TYPE_AQI_PM25_24H,
|
||||
name="AQI PM2.5 24h avg",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_AQI_PM25_IN,
|
||||
@@ -140,7 +139,6 @@ SENSOR_DESCRIPTIONS = (
|
||||
key=TYPE_AQI_PM25_IN_24H,
|
||||
name="AQI PM2.5 indoor 24h avg",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_BAROMABSIN,
|
||||
@@ -182,7 +180,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Event rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_FEELSLIKE,
|
||||
@@ -287,7 +285,6 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Last rain",
|
||||
icon="mdi:water",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_PER_DAY,
|
||||
@@ -315,7 +312,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Monthly rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_PM25_24H,
|
||||
@@ -586,7 +583,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Lifetime rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_UV,
|
||||
@@ -599,7 +596,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Weekly rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR,
|
||||
|
||||
@@ -17,6 +17,10 @@ from .device_trigger import (
|
||||
CONF_BUTTON_2,
|
||||
CONF_BUTTON_3,
|
||||
CONF_BUTTON_4,
|
||||
CONF_BUTTON_5,
|
||||
CONF_BUTTON_6,
|
||||
CONF_BUTTON_7,
|
||||
CONF_BUTTON_8,
|
||||
CONF_CLOSE,
|
||||
CONF_DIM_DOWN,
|
||||
CONF_DIM_UP,
|
||||
@@ -95,6 +99,10 @@ INTERFACES = {
|
||||
CONF_BUTTON_2: "Button 2",
|
||||
CONF_BUTTON_3: "Button 3",
|
||||
CONF_BUTTON_4: "Button 4",
|
||||
CONF_BUTTON_5: "Button 5",
|
||||
CONF_BUTTON_6: "Button 6",
|
||||
CONF_BUTTON_7: "Button 7",
|
||||
CONF_BUTTON_8: "Button 8",
|
||||
CONF_SIDE_1: "Side 1",
|
||||
CONF_SIDE_2: "Side 2",
|
||||
CONF_SIDE_3: "Side 3",
|
||||
|
||||
@@ -209,7 +209,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = (
|
||||
DSMRReaderSensorEntityDescription(
|
||||
key="dsmr/consumption/gas/currently_delivered",
|
||||
name="Current gas usage",
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
|
||||
@@ -153,6 +153,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if self._device_info.uses_password:
|
||||
return await self.async_step_authenticate()
|
||||
|
||||
self._password = ""
|
||||
return self._async_get_entry()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
|
||||
@@ -72,15 +72,13 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
||||
_attr_title = "ESPHome"
|
||||
_attr_name = "Firmware"
|
||||
|
||||
_device_info: ESPHomeDeviceInfo
|
||||
|
||||
def __init__(
|
||||
self, entry_data: RuntimeEntryData, coordinator: ESPHomeDashboard
|
||||
) -> None:
|
||||
"""Initialize the update entity."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
assert entry_data.device_info is not None
|
||||
self._device_info = entry_data.device_info
|
||||
self._entry_data = entry_data
|
||||
self._attr_unique_id = entry_data.device_info.mac_address
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={
|
||||
@@ -88,6 +86,12 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def _device_info(self) -> ESPHomeDeviceInfo:
|
||||
"""Return the device info."""
|
||||
assert self._entry_data.device_info is not None
|
||||
return self._entry_data.device_info
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if update is available."""
|
||||
|
||||
@@ -98,6 +98,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
for service_name in hass.services.async_services()[DOMAIN]:
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
if entry.options.get(CONF_ENABLE_CONVERSATION_AGENT, False):
|
||||
conversation.async_unset_agent(hass, entry)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Keenetic NDMS2 Router",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2",
|
||||
"requirements": ["ndms2_client==0.1.1"],
|
||||
"requirements": ["ndms2_client==0.1.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Matter (BETA)",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"requirements": ["python-matter-server==2.0.1"],
|
||||
"requirements": ["python-matter-server==2.0.2"],
|
||||
"dependencies": ["websocket_api"],
|
||||
"codeowners": ["@home-assistant/matter"],
|
||||
"iot_class": "local_push"
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfLength,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
@@ -41,7 +42,7 @@ SENSOR_DESCRIPTIONS = {
|
||||
"battery_voltage": SensorEntityDescription(
|
||||
key="battery_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant import exceptions
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry, entity_registry
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
@@ -41,37 +41,6 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp
|
||||
return bridge.locks, bridge.openers
|
||||
|
||||
|
||||
def _update_devices(devices: list[NukiDevice]) -> dict[str, set[str]]:
|
||||
"""
|
||||
Update the Nuki devices.
|
||||
|
||||
Returns:
|
||||
A dict with the events to be fired. The event type is the key and the device ids are the value
|
||||
"""
|
||||
|
||||
events: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for device in devices:
|
||||
for level in (False, True):
|
||||
try:
|
||||
if isinstance(device, NukiOpener):
|
||||
last_ring_action_state = device.ring_action_state
|
||||
|
||||
device.update(level)
|
||||
|
||||
if not last_ring_action_state and device.ring_action_state:
|
||||
events["ring"].add(device.nuki_id)
|
||||
else:
|
||||
device.update(level)
|
||||
except RequestException:
|
||||
continue
|
||||
|
||||
if device.state not in ERROR_STATES:
|
||||
break
|
||||
|
||||
return events
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the Nuki entry."""
|
||||
|
||||
@@ -101,42 +70,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except RequestException as err:
|
||||
raise exceptions.ConfigEntryNotReady from err
|
||||
|
||||
async def async_update_data() -> None:
|
||||
"""Fetch data from Nuki bridge."""
|
||||
try:
|
||||
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||
# handled by the data update coordinator.
|
||||
async with async_timeout.timeout(10):
|
||||
events = await hass.async_add_executor_job(
|
||||
_update_devices, locks + openers
|
||||
)
|
||||
except InvalidCredentialsException as err:
|
||||
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
|
||||
except RequestException as err:
|
||||
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
for event, device_ids in events.items():
|
||||
for device_id in device_ids:
|
||||
entity_id = ent_reg.async_get_entity_id(
|
||||
Platform.LOCK, DOMAIN, device_id
|
||||
)
|
||||
event_data = {
|
||||
"entity_id": entity_id,
|
||||
"type": event,
|
||||
}
|
||||
hass.bus.async_fire("nuki_event", event_data)
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
# Name of the data. For logging purposes.
|
||||
name="nuki devices",
|
||||
update_method=async_update_data,
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
# Device registration for the bridge
|
||||
info = bridge.info()
|
||||
bridge_id = parse_id(info["ids"]["hardwareId"])
|
||||
dev_reg = device_registry.async_get(hass)
|
||||
dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, bridge_id)},
|
||||
manufacturer="Nuki Home Solutions GmbH",
|
||||
name=f"Nuki Bridge {bridge_id}",
|
||||
model="Hardware Bridge",
|
||||
sw_version=info["versions"]["firmwareVersion"],
|
||||
)
|
||||
|
||||
coordinator = NukiCoordinator(hass, bridge, locks, openers)
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
DATA_COORDINATOR: coordinator,
|
||||
DATA_BRIDGE: bridge,
|
||||
@@ -178,3 +126,94 @@ class NukiEntity(CoordinatorEntity[DataUpdateCoordinator[None]]):
|
||||
"""Pass coordinator to CoordinatorEntity."""
|
||||
super().__init__(coordinator)
|
||||
self._nuki_device = nuki_device
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Device info for Nuki entities."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))},
|
||||
"name": self._nuki_device.name,
|
||||
"manufacturer": "Nuki Home Solutions GmbH",
|
||||
"model": self._nuki_device.device_type_str.capitalize(),
|
||||
"sw_version": self._nuki_device.firmware_version,
|
||||
"via_device": (DOMAIN, self.coordinator.bridge_id),
|
||||
}
|
||||
|
||||
|
||||
class NukiCoordinator(DataUpdateCoordinator):
|
||||
"""Data Update Coordinator for the Nuki integration."""
|
||||
|
||||
def __init__(self, hass, bridge, locks, openers):
|
||||
"""Initialize my coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
# Name of the data. For logging purposes.
|
||||
name="nuki devices",
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self.bridge = bridge
|
||||
self.locks = locks
|
||||
self.openers = openers
|
||||
|
||||
@property
|
||||
def bridge_id(self):
|
||||
"""Return the parsed id of the Nuki bridge."""
|
||||
return parse_id(self.bridge.info()["ids"]["hardwareId"])
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from Nuki bridge."""
|
||||
try:
|
||||
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
|
||||
# handled by the data update coordinator.
|
||||
async with async_timeout.timeout(10):
|
||||
events = await self.hass.async_add_executor_job(
|
||||
self.update_devices, self.locks + self.openers
|
||||
)
|
||||
except InvalidCredentialsException as err:
|
||||
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
|
||||
except RequestException as err:
|
||||
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
|
||||
|
||||
ent_reg = entity_registry.async_get(self.hass)
|
||||
for event, device_ids in events.items():
|
||||
for device_id in device_ids:
|
||||
entity_id = ent_reg.async_get_entity_id(
|
||||
Platform.LOCK, DOMAIN, device_id
|
||||
)
|
||||
event_data = {
|
||||
"entity_id": entity_id,
|
||||
"type": event,
|
||||
}
|
||||
self.hass.bus.async_fire("nuki_event", event_data)
|
||||
|
||||
def update_devices(self, devices: list[NukiDevice]) -> dict[str, set[str]]:
|
||||
"""
|
||||
Update the Nuki devices.
|
||||
|
||||
Returns:
|
||||
A dict with the events to be fired. The event type is the key and the device ids are the value
|
||||
"""
|
||||
|
||||
events: dict[str, set[str]] = defaultdict(set)
|
||||
|
||||
for device in devices:
|
||||
for level in (False, True):
|
||||
try:
|
||||
if isinstance(device, NukiOpener):
|
||||
last_ring_action_state = device.ring_action_state
|
||||
|
||||
device.update(level)
|
||||
|
||||
if not last_ring_action_state and device.ring_action_state:
|
||||
events["ring"].add(device.nuki_id)
|
||||
else:
|
||||
device.update(level)
|
||||
except RequestException:
|
||||
continue
|
||||
|
||||
if device.state not in ERROR_STATES:
|
||||
break
|
||||
|
||||
return events
|
||||
|
||||
@@ -34,13 +34,10 @@ async def async_setup_entry(
|
||||
class NukiDoorsensorEntity(NukiEntity, BinarySensorEntity):
|
||||
"""Representation of a Nuki Lock Doorsensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Door sensor"
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the lock."""
|
||||
return self._nuki_device.name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
|
||||
@@ -67,13 +67,9 @@ async def async_setup_entry(
|
||||
class NukiDeviceEntity(NukiEntity, LockEntity, ABC):
|
||||
"""Representation of a Nuki device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = LockEntityFeature.OPEN
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the lock."""
|
||||
return self._nuki_device.name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return a unique ID."""
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import openai
|
||||
from openai import error
|
||||
@@ -13,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, TemplateError
|
||||
from homeassistant.helpers import area_registry, device_registry, intent, template
|
||||
from homeassistant.helpers import area_registry, intent, template
|
||||
from homeassistant.util import ulid
|
||||
|
||||
from .const import DEFAULT_MODEL, DEFAULT_PROMPT
|
||||
@@ -74,6 +73,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
|
||||
try:
|
||||
prompt = self._async_generate_prompt()
|
||||
except TemplateError as err:
|
||||
_LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
@@ -97,15 +97,26 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
|
||||
|
||||
_LOGGER.debug("Prompt for %s: %s", model, prompt)
|
||||
|
||||
result = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
openai.Completion.create,
|
||||
engine=model,
|
||||
prompt=prompt,
|
||||
max_tokens=150,
|
||||
user=conversation_id,
|
||||
try:
|
||||
result = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
openai.Completion.create,
|
||||
engine=model,
|
||||
prompt=prompt,
|
||||
max_tokens=150,
|
||||
user=conversation_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
except error.OpenAIError as err:
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
f"Sorry, I had a problem talking to OpenAI: {err}",
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
_LOGGER.debug("Response %s", result)
|
||||
response = result["choices"][0]["text"].strip()
|
||||
self.history[conversation_id] = prompt + response
|
||||
@@ -122,20 +133,9 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
|
||||
|
||||
def _async_generate_prompt(self) -> str:
|
||||
"""Generate a prompt for the user."""
|
||||
dev_reg = device_registry.async_get(self.hass)
|
||||
return template.Template(DEFAULT_PROMPT, self.hass).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"areas": [
|
||||
area
|
||||
for area in area_registry.async_get(self.hass).areas.values()
|
||||
# Filter out areas without devices
|
||||
if any(
|
||||
not dev.disabled_by
|
||||
for dev in device_registry.async_entries_for_area(
|
||||
dev_reg, cast(str, area.id)
|
||||
)
|
||||
)
|
||||
],
|
||||
"areas": list(area_registry.async_get(self.hass).areas.values()),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,19 +3,26 @@
|
||||
DOMAIN = "openai_conversation"
|
||||
CONF_PROMPT = "prompt"
|
||||
DEFAULT_MODEL = "text-davinci-003"
|
||||
DEFAULT_PROMPT = """
|
||||
You are a conversational AI for a smart home named {{ ha_name }}.
|
||||
If a user wants to control a device, reject the request and suggest using the Home Assistant UI.
|
||||
DEFAULT_PROMPT = """This smart home is controlled by Home Assistant.
|
||||
|
||||
An overview of the areas and the devices in this smart home:
|
||||
{% for area in areas %}
|
||||
{%- for area in areas %}
|
||||
{%- set area_info = namespace(printed=false) %}
|
||||
{%- for device in area_devices(area.name) -%}
|
||||
{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") %}
|
||||
{%- if not area_info.printed %}
|
||||
|
||||
{{ area.name }}:
|
||||
{% for device in area_devices(area.name) -%}
|
||||
{%- if not device_attr(device, "disabled_by") %}
|
||||
- {{ device_attr(device, "name") }} ({{ device_attr(device, "model") }} by {{ device_attr(device, "manufacturer") }})
|
||||
{%- endif %}
|
||||
{%- set area_info.printed = true %}
|
||||
{%- endif %}
|
||||
- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and device_attr(device, "model") not in device_attr(device, "name") %} ({{ device_attr(device, "model") }}){% endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
{% endfor %}
|
||||
|
||||
Answer the users questions about the world truthfully.
|
||||
|
||||
If the user wants to control a device, reject the request and suggest using the Home Assistant app.
|
||||
|
||||
Now finish this conversation:
|
||||
|
||||
|
||||
@@ -836,7 +836,9 @@ class Recorder(threading.Thread):
|
||||
return
|
||||
|
||||
try:
|
||||
shared_data_bytes = EventData.shared_data_bytes_from_event(event)
|
||||
shared_data_bytes = EventData.shared_data_bytes_from_event(
|
||||
event, self.dialect_name
|
||||
)
|
||||
except JSON_ENCODE_EXCEPTIONS as ex:
|
||||
_LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex)
|
||||
return
|
||||
@@ -869,7 +871,7 @@ class Recorder(threading.Thread):
|
||||
try:
|
||||
dbstate = States.from_event(event)
|
||||
shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event(
|
||||
event, self._exclude_attributes_by_domain
|
||||
event, self._exclude_attributes_by_domain, self.dialect_name
|
||||
)
|
||||
except JSON_ENCODE_EXCEPTIONS as ex:
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -43,11 +43,12 @@ from homeassistant.helpers.json import (
|
||||
JSON_DECODE_EXCEPTIONS,
|
||||
JSON_DUMP,
|
||||
json_bytes,
|
||||
json_bytes_strip_null,
|
||||
json_loads,
|
||||
)
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .const import ALL_DOMAIN_EXCLUDE_ATTRS
|
||||
from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect
|
||||
from .models import StatisticData, StatisticMetaData, process_timestamp
|
||||
|
||||
# SQLAlchemy Schema
|
||||
@@ -251,8 +252,12 @@ class EventData(Base): # type: ignore[misc,valid-type]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def shared_data_bytes_from_event(event: Event) -> bytes:
|
||||
def shared_data_bytes_from_event(
|
||||
event: Event, dialect: SupportedDialect | None
|
||||
) -> bytes:
|
||||
"""Create shared_data from an event."""
|
||||
if dialect == SupportedDialect.POSTGRESQL:
|
||||
return json_bytes_strip_null(event.data)
|
||||
return json_bytes(event.data)
|
||||
|
||||
@staticmethod
|
||||
@@ -416,7 +421,9 @@ class StateAttributes(Base): # type: ignore[misc,valid-type]
|
||||
|
||||
@staticmethod
|
||||
def shared_attrs_bytes_from_event(
|
||||
event: Event, exclude_attrs_by_domain: dict[str, set[str]]
|
||||
event: Event,
|
||||
exclude_attrs_by_domain: dict[str, set[str]],
|
||||
dialect: SupportedDialect | None,
|
||||
) -> bytes:
|
||||
"""Create shared_attrs from a state_changed event."""
|
||||
state: State | None = event.data.get("new_state")
|
||||
@@ -427,6 +434,10 @@ class StateAttributes(Base): # type: ignore[misc,valid-type]
|
||||
exclude_attrs = (
|
||||
exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS
|
||||
)
|
||||
if dialect == SupportedDialect.POSTGRESQL:
|
||||
return json_bytes_strip_null(
|
||||
{k: v for k, v in state.attributes.items() if k not in exclude_attrs}
|
||||
)
|
||||
return json_bytes(
|
||||
{k: v for k, v in state.attributes.items() if k not in exclude_attrs}
|
||||
)
|
||||
|
||||
@@ -665,6 +665,7 @@ class SensorEntity(Entity):
|
||||
(
|
||||
"Entity %s (%s) is using native unit of measurement '%s' which "
|
||||
"is not a valid unit for the device class ('%s') it is using; "
|
||||
"expected one of %s; "
|
||||
"Please update your configuration if your entity is manually "
|
||||
"configured, otherwise %s"
|
||||
),
|
||||
@@ -672,6 +673,7 @@ class SensorEntity(Entity):
|
||||
type(self),
|
||||
native_unit_of_measurement,
|
||||
device_class,
|
||||
[str(unit) if unit else "no unit of measurement" for unit in units],
|
||||
report_issue,
|
||||
)
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass | None]
|
||||
SensorDeviceClass.DATA_SIZE: set(SensorStateClass),
|
||||
SensorDeviceClass.DATE: set(),
|
||||
SensorDeviceClass.DISTANCE: set(SensorStateClass),
|
||||
SensorDeviceClass.DURATION: set(),
|
||||
SensorDeviceClass.DURATION: set(SensorStateClass),
|
||||
SensorDeviceClass.ENERGY: {
|
||||
SensorStateClass.TOTAL,
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Shelly",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
||||
"requirements": ["aioshelly==5.3.0"],
|
||||
"requirements": ["aioshelly==5.3.1"],
|
||||
"dependencies": ["bluetooth", "http"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ button:
|
||||
description: >-
|
||||
Name of the button to press. Known possible values are
|
||||
LEFT, RIGHT, DOWN, UP, HOME, MENU, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT,
|
||||
MUTE, RED, GREEN, BLUE, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN,
|
||||
MUTE, RED, GREEN, BLUE, YELLOW, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN,
|
||||
PLAY, PAUSE, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
|
||||
required: true
|
||||
example: "LEFT"
|
||||
|
||||
@@ -224,7 +224,8 @@ class InovelliConfigEntityChannel(ZigbeeChannel):
|
||||
"switch_type": False,
|
||||
"button_delay": False,
|
||||
"smart_bulb_mode": False,
|
||||
"double_tap_up_for_full_brightness": True,
|
||||
"double_tap_up_for_max_brightness": True,
|
||||
"double_tap_down_for_min_brightness": True,
|
||||
"led_color_when_on": True,
|
||||
"led_color_when_off": True,
|
||||
"led_intensity_when_on": True,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"bellows==0.34.6",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.91",
|
||||
"zha-quirks==0.0.92",
|
||||
"zigpy-deconz==0.19.2",
|
||||
"zigpy==0.53.0",
|
||||
"zigpy-xbee==0.16.2",
|
||||
|
||||
@@ -372,14 +372,26 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_
|
||||
channel_names=CHANNEL_INOVELLI,
|
||||
)
|
||||
class InovelliDoubleTapForFullBrightness(
|
||||
ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_full_brightness"
|
||||
ZHASwitchConfigurationEntity, id_suffix="double_tap_up_for_max_brightness"
|
||||
):
|
||||
"""Inovelli double tap for full brightness control."""
|
||||
|
||||
_zcl_attribute: str = "double_tap_up_for_full_brightness"
|
||||
_zcl_attribute: str = "double_tap_up_for_max_brightness"
|
||||
_attr_name: str = "Double tap full brightness"
|
||||
|
||||
|
||||
@CONFIG_DIAGNOSTIC_MATCH(
|
||||
channel_names=CHANNEL_INOVELLI,
|
||||
)
|
||||
class InovelliDoubleTapForMinBrightness(
|
||||
ZHASwitchConfigurationEntity, id_suffix="double_tap_down_for_min_brightness"
|
||||
):
|
||||
"""Inovelli double tap down for minimum brightness control."""
|
||||
|
||||
_zcl_attribute: str = "double_tap_down_for_min_brightness"
|
||||
_attr_name: str = "Double tap minimum brightness"
|
||||
|
||||
|
||||
@CONFIG_DIAGNOSTIC_MATCH(
|
||||
channel_names=CHANNEL_INOVELLI,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "0b0"
|
||||
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, 10, 0)
|
||||
|
||||
@@ -13,7 +13,7 @@ from collections.abc import Awaitable, Callable
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, cast
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from aiohttp import client, web
|
||||
import async_timeout
|
||||
@@ -437,7 +437,10 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView):
|
||||
state = _decode_jwt(hass, request.query["state"])
|
||||
|
||||
if state is None:
|
||||
return web.Response(text="Invalid state")
|
||||
return web.Response(
|
||||
text="Invalid state. Is My Home Assistant configured to go to the right instance?",
|
||||
status=400,
|
||||
)
|
||||
|
||||
user_input: dict[str, Any] = {"state": state}
|
||||
|
||||
@@ -538,7 +541,10 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str:
|
||||
@callback
|
||||
def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None:
|
||||
"""JWT encode data."""
|
||||
secret = cast(str, hass.data.get(DATA_JWT_SECRET))
|
||||
secret = cast(Optional[str], hass.data.get(DATA_JWT_SECRET))
|
||||
|
||||
if secret is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return jwt.decode(encoded, secret, algorithms=["HS256"])
|
||||
|
||||
@@ -71,6 +71,40 @@ def json_bytes(data: Any) -> bytes:
|
||||
)
|
||||
|
||||
|
||||
def json_bytes_strip_null(data: Any) -> bytes:
|
||||
"""Dump json bytes after terminating strings at the first NUL."""
|
||||
|
||||
def process_dict(_dict: dict[Any, Any]) -> dict[Any, Any]:
|
||||
"""Strip NUL from items in a dict."""
|
||||
return {key: strip_null(o) for key, o in _dict.items()}
|
||||
|
||||
def process_list(_list: list[Any]) -> list[Any]:
|
||||
"""Strip NUL from items in a list."""
|
||||
return [strip_null(o) for o in _list]
|
||||
|
||||
def strip_null(obj: Any) -> Any:
|
||||
"""Strip NUL from an object."""
|
||||
if isinstance(obj, str):
|
||||
return obj.split("\0", 1)[0]
|
||||
if isinstance(obj, dict):
|
||||
return process_dict(obj)
|
||||
if isinstance(obj, list):
|
||||
return process_list(obj)
|
||||
return obj
|
||||
|
||||
# We expect null-characters to be very rare, hence try encoding first and look
|
||||
# for an escaped null-character in the output.
|
||||
result = json_bytes(data)
|
||||
if b"\\u0000" in result:
|
||||
# We work on the processed result so we don't need to worry about
|
||||
# Home Assistant extensions which allows encoding sets, tuples, etc.
|
||||
data_processed = orjson.loads(result)
|
||||
data_processed = strip_null(data_processed)
|
||||
result = json_bytes(data_processed)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def json_dumps(data: Any) -> str:
|
||||
"""Dump json string.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2023.2.0b0"
|
||||
version = "2023.2.0b2"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
||||
@@ -267,7 +267,7 @@ aiosenseme==0.6.1
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==5.3.0
|
||||
aioshelly==5.3.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -1168,7 +1168,7 @@ mycroftapi==2.0
|
||||
nad_receiver==0.3.0
|
||||
|
||||
# homeassistant.components.keenetic_ndms2
|
||||
ndms2_client==0.1.1
|
||||
ndms2_client==0.1.2
|
||||
|
||||
# homeassistant.components.ness_alarm
|
||||
nessclient==0.10.0
|
||||
@@ -2072,7 +2072,7 @@ python-kasa==0.5.0
|
||||
# python-lirc==1.2.3
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==2.0.1
|
||||
python-matter-server==2.0.2
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
@@ -2693,7 +2693,7 @@ zeroconf==0.47.1
|
||||
zeversolar==0.2.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.91
|
||||
zha-quirks==0.0.92
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong_hong_hvac==1.0.9
|
||||
|
||||
@@ -245,7 +245,7 @@ aiosenseme==0.6.1
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==5.3.0
|
||||
aioshelly==5.3.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -867,7 +867,7 @@ mutagen==1.46.0
|
||||
mutesync==0.0.1
|
||||
|
||||
# homeassistant.components.keenetic_ndms2
|
||||
ndms2_client==0.1.1
|
||||
ndms2_client==0.1.2
|
||||
|
||||
# homeassistant.components.ness_alarm
|
||||
nessclient==0.10.0
|
||||
@@ -1468,7 +1468,7 @@ python-juicenet==1.1.0
|
||||
python-kasa==0.5.0
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==2.0.1
|
||||
python-matter-server==2.0.2
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
@@ -1906,7 +1906,7 @@ zeroconf==0.47.1
|
||||
zeversolar==0.2.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.91
|
||||
zha-quirks==0.0.92
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.19.2
|
||||
|
||||
@@ -518,6 +518,54 @@ async def test_reauth_fixed_via_dashboard(
|
||||
assert len(mock_get_encryption_key.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_fixed_via_dashboard_remove_password(
|
||||
hass, mock_client, mock_zeroconf, mock_dashboard
|
||||
):
|
||||
"""Test reauth fixed automatically via dashboard with password removed."""
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_HOST: "127.0.0.1",
|
||||
CONF_PORT: 6053,
|
||||
CONF_PASSWORD: "hello",
|
||||
CONF_DEVICE_NAME: "test",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
|
||||
|
||||
mock_dashboard["configured"].append(
|
||||
{
|
||||
"name": "test",
|
||||
"configuration": "test.yaml",
|
||||
}
|
||||
)
|
||||
|
||||
await dashboard.async_get_dashboard(hass).async_refresh()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key",
|
||||
return_value=VALID_NOISE_PSK,
|
||||
) as mock_get_encryption_key:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"esphome",
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": entry.entry_id,
|
||||
"unique_id": entry.unique_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT, result
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||
assert entry.data[CONF_PASSWORD] == ""
|
||||
|
||||
assert len(mock_get_encryption_key.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf):
|
||||
"""Test reauth initiation with invalid PSK."""
|
||||
entry = MockConfigEntry(
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"""Tests for the OpenAI integration."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openai import error
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.helpers import device_registry
|
||||
from homeassistant.helpers import area_registry, device_registry, intent
|
||||
|
||||
|
||||
async def test_default_prompt(hass, mock_init_component):
|
||||
"""Test that the default prompt works."""
|
||||
device_reg = device_registry.async_get(hass)
|
||||
area_reg = area_registry.async_get(hass)
|
||||
|
||||
for i in range(3):
|
||||
area_reg.async_create(f"{i}Empty Area")
|
||||
|
||||
device_reg.async_get_or_create(
|
||||
config_entry_id="1234",
|
||||
@@ -18,12 +24,22 @@ async def test_default_prompt(hass, mock_init_component):
|
||||
model="Test Model",
|
||||
suggested_area="Test Area",
|
||||
)
|
||||
for i in range(3):
|
||||
device_reg.async_get_or_create(
|
||||
config_entry_id="1234",
|
||||
connections={("test", f"{i}abcd")},
|
||||
name="Test Service",
|
||||
manufacturer="Test Manufacturer",
|
||||
model="Test Model",
|
||||
suggested_area="Test Area",
|
||||
entry_type=device_registry.DeviceEntryType.SERVICE,
|
||||
)
|
||||
device_reg.async_get_or_create(
|
||||
config_entry_id="1234",
|
||||
connections={("test", "5678")},
|
||||
name="Test Device 2",
|
||||
manufacturer="Test Manufacturer 2",
|
||||
model="Test Model 2",
|
||||
model="Device 2",
|
||||
suggested_area="Test Area 2",
|
||||
)
|
||||
device_reg.async_get_or_create(
|
||||
@@ -31,29 +47,49 @@ async def test_default_prompt(hass, mock_init_component):
|
||||
connections={("test", "9876")},
|
||||
name="Test Device 3",
|
||||
manufacturer="Test Manufacturer 3",
|
||||
model="Test Model 3",
|
||||
model="Test Model 3A",
|
||||
suggested_area="Test Area 2",
|
||||
)
|
||||
device_reg.async_get_or_create(
|
||||
config_entry_id="1234",
|
||||
connections={("test", "qwer")},
|
||||
name="Test Device 4",
|
||||
suggested_area="Test Area 2",
|
||||
)
|
||||
device = device_reg.async_get_or_create(
|
||||
config_entry_id="1234",
|
||||
connections={("test", "9876-disabled")},
|
||||
name="Test Device 3",
|
||||
manufacturer="Test Manufacturer 3",
|
||||
model="Test Model 3A",
|
||||
suggested_area="Test Area 2",
|
||||
)
|
||||
device_reg.async_update_device(
|
||||
device.id, disabled_by=device_registry.DeviceEntryDisabler.USER
|
||||
)
|
||||
|
||||
with patch("openai.Completion.create") as mock_create:
|
||||
await conversation.async_converse(hass, "hello", None, Context())
|
||||
result = await conversation.async_converse(hass, "hello", None, Context())
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
|
||||
assert (
|
||||
mock_create.mock_calls[0][2]["prompt"]
|
||||
== """You are a conversational AI for a smart home named test home.
|
||||
If a user wants to control a device, reject the request and suggest using the Home Assistant UI.
|
||||
== """This smart home is controlled by Home Assistant.
|
||||
|
||||
An overview of the areas and the devices in this smart home:
|
||||
|
||||
Test Area:
|
||||
|
||||
- Test Device (Test Model by Test Manufacturer)
|
||||
- Test Device (Test Model)
|
||||
|
||||
Test Area 2:
|
||||
- Test Device 2
|
||||
- Test Device 3 (Test Model 3A)
|
||||
- Test Device 4
|
||||
|
||||
- Test Device 2 (Test Model 2 by Test Manufacturer 2)
|
||||
- Test Device 3 (Test Model 3 by Test Manufacturer 3)
|
||||
Answer the users questions about the world truthfully.
|
||||
|
||||
If the user wants to control a device, reject the request and suggest using the Home Assistant app.
|
||||
|
||||
Now finish this conversation:
|
||||
|
||||
@@ -61,3 +97,12 @@ Smart home: How can I assist?
|
||||
User: hello
|
||||
Smart home: """
|
||||
)
|
||||
|
||||
|
||||
async def test_error_handling(hass, mock_init_component):
|
||||
"""Test that the default prompt works."""
|
||||
with patch("openai.Completion.create", side_effect=error.ServiceUnavailableError):
|
||||
result = await conversation.async_converse(hass, "hello", None, Context())
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR, result
|
||||
assert result.response.error_code == "unknown", result
|
||||
|
||||
@@ -34,6 +34,7 @@ from sqlalchemy.ext.declarative import declared_attr
|
||||
from sqlalchemy.orm import aliased, declarative_base, relationship
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from homeassistant.components.recorder.const import SupportedDialect
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
ATTR_RESTORED,
|
||||
@@ -287,7 +288,9 @@ class EventData(Base): # type: ignore[misc,valid-type]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def shared_data_bytes_from_event(event: Event) -> bytes:
|
||||
def shared_data_bytes_from_event(
|
||||
event: Event, dialect: SupportedDialect | None
|
||||
) -> bytes:
|
||||
"""Create shared_data from an event."""
|
||||
return json_bytes(event.data)
|
||||
|
||||
@@ -438,7 +441,9 @@ class StateAttributes(Base): # type: ignore[misc,valid-type]
|
||||
|
||||
@staticmethod
|
||||
def shared_attrs_bytes_from_event(
|
||||
event: Event, exclude_attrs_by_domain: dict[str, set[str]]
|
||||
event: Event,
|
||||
exclude_attrs_by_domain: dict[str, set[str]],
|
||||
dialect: SupportedDialect | None,
|
||||
) -> bytes:
|
||||
"""Create shared_attrs from a state_changed event."""
|
||||
state: State | None = event.data.get("new_state")
|
||||
|
||||
@@ -31,6 +31,7 @@ from homeassistant.components.recorder.const import (
|
||||
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
|
||||
EVENT_RECORDER_HOURLY_STATISTICS_GENERATED,
|
||||
KEEPALIVE_TIME,
|
||||
SupportedDialect,
|
||||
)
|
||||
from homeassistant.components.recorder.db_schema import (
|
||||
SCHEMA_VERSION,
|
||||
@@ -223,6 +224,42 @@ async def test_saving_state(recorder_mock, hass: HomeAssistant):
|
||||
assert state == _state_with_context(hass, entity_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dialect_name, expected_attributes",
|
||||
(
|
||||
(SupportedDialect.MYSQL, {"test_attr": 5, "test_attr_10": "silly\0stuff"}),
|
||||
(SupportedDialect.POSTGRESQL, {"test_attr": 5, "test_attr_10": "silly"}),
|
||||
(SupportedDialect.SQLITE, {"test_attr": 5, "test_attr_10": "silly\0stuff"}),
|
||||
),
|
||||
)
|
||||
async def test_saving_state_with_nul(
|
||||
recorder_mock, hass: HomeAssistant, dialect_name, expected_attributes
|
||||
):
|
||||
"""Test saving and restoring a state with nul in attributes."""
|
||||
entity_id = "test.recorder"
|
||||
state = "restoring_from_db"
|
||||
attributes = {"test_attr": 5, "test_attr_10": "silly\0stuff"}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.recorder.core.Recorder.dialect_name", dialect_name
|
||||
):
|
||||
hass.states.async_set(entity_id, state, attributes)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
with session_scope(hass=hass) as session:
|
||||
db_states = []
|
||||
for db_state, db_state_attributes in session.query(States, StateAttributes):
|
||||
db_states.append(db_state)
|
||||
state = db_state.to_native()
|
||||
state.attributes = db_state_attributes.to_native()
|
||||
assert len(db_states) == 1
|
||||
assert db_states[0].event_id is None
|
||||
|
||||
expected = _state_with_context(hass, entity_id)
|
||||
expected.attributes = expected_attributes
|
||||
assert state == expected
|
||||
|
||||
|
||||
async def test_saving_many_states(
|
||||
async_setup_recorder_instance: SetupRecorderInstanceT, hass: HomeAssistant
|
||||
):
|
||||
|
||||
@@ -9,7 +9,11 @@ import pytest
|
||||
from pytest import approx
|
||||
|
||||
from homeassistant.components.number import NumberDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.components.sensor import (
|
||||
DEVICE_CLASS_UNITS,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
PERCENTAGE,
|
||||
@@ -1403,13 +1407,17 @@ async def test_device_classes_with_invalid_unit_of_measurement(
|
||||
device_class=device_class,
|
||||
native_unit_of_measurement="INVALID!",
|
||||
)
|
||||
|
||||
units = [
|
||||
str(unit) if unit else "no unit of measurement"
|
||||
for unit in DEVICE_CLASS_UNITS.get(device_class, set())
|
||||
]
|
||||
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
"is using native unit of measurement 'INVALID!' which is not a valid "
|
||||
f"unit for the device class ('{device_class}') it is using"
|
||||
f"unit for the device class ('{device_class}') it is using; "
|
||||
f"expected one of {units}"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
|
||||
@@ -726,3 +726,10 @@ async def test_oauth_session_refresh_failure(
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl)
|
||||
with pytest.raises(aiohttp.client_exceptions.ClientResponseError):
|
||||
await session.async_request("post", "https://example.com")
|
||||
|
||||
|
||||
async def test_oauth2_without_secret_init(local_impl, hass_client_no_auth):
|
||||
"""Check authorize callback without secret initalizated."""
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get("/auth/external/callback?code=abcd&state=qwer")
|
||||
assert resp.status == 400
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant import core
|
||||
from homeassistant.helpers.json import (
|
||||
ExtendedJSONEncoder,
|
||||
JSONEncoder,
|
||||
json_bytes_strip_null,
|
||||
json_dumps,
|
||||
json_dumps_sorted,
|
||||
)
|
||||
@@ -118,3 +119,19 @@ def test_json_dumps_rgb_color_subclass():
|
||||
rgb = RGBColor(4, 2, 1)
|
||||
|
||||
assert json_dumps(rgb) == "[4,2,1]"
|
||||
|
||||
|
||||
def test_json_bytes_strip_null():
|
||||
"""Test stripping nul from strings."""
|
||||
|
||||
assert json_bytes_strip_null("\0") == b'""'
|
||||
assert json_bytes_strip_null("silly\0stuff") == b'"silly"'
|
||||
assert json_bytes_strip_null(["one", "two\0", "three"]) == b'["one","two","three"]'
|
||||
assert (
|
||||
json_bytes_strip_null({"k1": "one", "k2": "two\0", "k3": "three"})
|
||||
== b'{"k1":"one","k2":"two","k3":"three"}'
|
||||
)
|
||||
assert (
|
||||
json_bytes_strip_null([[{"k1": {"k2": ["silly\0stuff"]}}]])
|
||||
== b'[[{"k1":{"k2":["silly"]}}]]'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user