This commit is contained in:
Franck Nijhof 2024-03-14 16:53:07 +01:00 committed by GitHub
commit f7972ce9b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 2097 additions and 452 deletions

View File

@ -639,6 +639,12 @@ omit =
homeassistant/components/izone/climate.py homeassistant/components/izone/climate.py
homeassistant/components/izone/discovery.py homeassistant/components/izone/discovery.py
homeassistant/components/joaoapps_join/* homeassistant/components/joaoapps_join/*
homeassistant/components/juicenet/__init__.py
homeassistant/components/juicenet/device.py
homeassistant/components/juicenet/entity.py
homeassistant/components/juicenet/number.py
homeassistant/components/juicenet/sensor.py
homeassistant/components/juicenet/switch.py
homeassistant/components/justnimbus/coordinator.py homeassistant/components/justnimbus/coordinator.py
homeassistant/components/justnimbus/entity.py homeassistant/components/justnimbus/entity.py
homeassistant/components/justnimbus/sensor.py homeassistant/components/justnimbus/sensor.py

View File

@ -669,6 +669,8 @@ build.json @home-assistant/supervisor
/tests/components/jellyfin/ @j-stienstra @ctalkington /tests/components/jellyfin/ @j-stienstra @ctalkington
/homeassistant/components/jewish_calendar/ @tsvi /homeassistant/components/jewish_calendar/ @tsvi
/tests/components/jewish_calendar/ @tsvi /tests/components/jewish_calendar/ @tsvi
/homeassistant/components/juicenet/ @jesserockz
/tests/components/juicenet/ @jesserockz
/homeassistant/components/justnimbus/ @kvanzuijlen /homeassistant/components/justnimbus/ @kvanzuijlen
/tests/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi /homeassistant/components/jvc_projector/ @SteveEasley @msavazzi

View File

@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
address = entry.unique_id address = entry.unique_id
elevation = hass.config.elevation
is_metric = hass.config.units is METRIC_SYSTEM is_metric = hass.config.units is METRIC_SYSTEM
assert address is not None assert address is not None
@ -40,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
f"Could not find Airthings device with address {address}" f"Could not find Airthings device with address {address}"
) )
airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric)
async def _async_update_method() -> AirthingsDevice: async def _async_update_method() -> AirthingsDevice:
"""Get data from Airthings BLE.""" """Get data from Airthings BLE."""

View File

@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/airthings_ble", "documentation": "https://www.home-assistant.io/integrations/airthings_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["airthings-ble==0.6.1"] "requirements": ["airthings-ble==0.7.1"]
} }

View File

@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE, PERCENTAGE,
EntityCategory, EntityCategory,
Platform, Platform,
@ -106,8 +105,7 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
), ),
"illuminance": SensorEntityDescription( "illuminance": SensorEntityDescription(
key="illuminance", key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=PERCENTAGE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
} }
@ -222,7 +220,7 @@ class AirthingsSensor(
manufacturer=airthings_device.manufacturer, manufacturer=airthings_device.manufacturer,
hw_version=airthings_device.hw_version, hw_version=airthings_device.hw_version,
sw_version=airthings_device.sw_version, sw_version=airthings_device.sw_version,
model=airthings_device.model, model=airthings_device.model.name,
) )
@property @property

View File

@ -31,9 +31,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async_get_clientsession(hass), async_get_clientsession(hass),
) )
# Ignore services that don't support usage data
ignore_types = FETCH_TYPES + ["Hardware"]
try: try:
await client.login() await client.login()
services = await client.get_services(drop_types=FETCH_TYPES) services = await client.get_services(drop_types=ignore_types)
except AuthenticationException as exc: except AuthenticationException as exc:
raise ConfigEntryAuthFailed() from exc raise ConfigEntryAuthFailed() from exc
except ClientError as exc: except ClientError as exc:

View File

@ -26,7 +26,7 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["axis"], "loggers": ["axis"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["axis==50"], "requirements": ["axis==54"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "AXIS" "manufacturer": "AXIS"

View File

@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bosch_shc", "documentation": "https://www.home-assistant.io/integrations/bosch_shc",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["boschshcpy"], "loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.2.75"], "requirements": ["boschshcpy==0.2.82"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_http._tcp.local.", "type": "_http._tcp.local.",

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bring", "documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["bring-api==0.5.5"] "requirements": ["bring-api==0.5.6"]
} }

View File

@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
brother = await Brother.create( brother = await Brother.create(
host, printer_type=printer_type, snmp_engine=snmp_engine host, printer_type=printer_type, snmp_engine=snmp_engine
) )
except (ConnectionError, SnmpError) as error: except (ConnectionError, SnmpError, TimeoutError) as error:
raise ConfigEntryNotReady from error raise ConfigEntryNotReady from error
coordinator = BrotherDataUpdateCoordinator(hass, brother) coordinator = BrotherDataUpdateCoordinator(hass, brother)

View File

@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["brother==4.0.0"], "requirements": ["brother==4.0.2"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_printer._tcp.local.", "type": "_printer._tcp.local.",

View File

@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome", "documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bthome-ble==3.6.0"] "requirements": ["bthome-ble==3.8.0"]
} }

View File

@ -189,6 +189,11 @@ def _validate_rrule(value: Any) -> str:
return str(value) return str(value)
def _empty_as_none(value: str | None) -> str | None:
"""Convert any empty string values to None."""
return value or None
CREATE_EVENT_SERVICE = "create_event" CREATE_EVENT_SERVICE = "create_event"
CREATE_EVENT_SCHEMA = vol.All( CREATE_EVENT_SCHEMA = vol.All(
cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN),
@ -733,7 +738,9 @@ async def handle_calendar_event_create(
vol.Required("type"): "calendar/event/delete", vol.Required("type"): "calendar/event/delete",
vol.Required("entity_id"): cv.entity_id, vol.Required("entity_id"): cv.entity_id,
vol.Required(EVENT_UID): cv.string, vol.Required(EVENT_UID): cv.string,
vol.Optional(EVENT_RECURRENCE_ID): cv.string, vol.Optional(EVENT_RECURRENCE_ID): vol.Any(
vol.All(cv.string, _empty_as_none), None
),
vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, vol.Optional(EVENT_RECURRENCE_RANGE): cv.string,
} }
) )
@ -777,7 +784,9 @@ async def handle_calendar_event_delete(
vol.Required("type"): "calendar/event/update", vol.Required("type"): "calendar/event/update",
vol.Required("entity_id"): cv.entity_id, vol.Required("entity_id"): cv.entity_id,
vol.Required(EVENT_UID): cv.string, vol.Required(EVENT_UID): cv.string,
vol.Optional(EVENT_RECURRENCE_ID): cv.string, vol.Optional(EVENT_RECURRENCE_ID): vol.Any(
vol.All(cv.string, _empty_as_none), None
),
vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, vol.Optional(EVENT_RECURRENCE_RANGE): cv.string,
vol.Required(CONF_EVENT): WEBSOCKET_EVENT_SCHEMA, vol.Required(CONF_EVENT): WEBSOCKET_EVENT_SCHEMA,
} }

View File

@ -12,6 +12,7 @@ from homeassistant.components.media_source.models import (
PlayMedia, PlayMedia,
) )
from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
@ -25,13 +26,20 @@ async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource:
return CameraMediaSource(hass) return CameraMediaSource(hass)
def _media_source_for_camera(camera: Camera, content_type: str) -> BrowseMediaSource: def _media_source_for_camera(
hass: HomeAssistant, camera: Camera, content_type: str
) -> BrowseMediaSource:
camera_state = hass.states.get(camera.entity_id)
title = camera.name
if camera_state:
title = camera_state.attributes.get(ATTR_FRIENDLY_NAME, camera.name)
return BrowseMediaSource( return BrowseMediaSource(
domain=DOMAIN, domain=DOMAIN,
identifier=camera.entity_id, identifier=camera.entity_id,
media_class=MediaClass.VIDEO, media_class=MediaClass.VIDEO,
media_content_type=content_type, media_content_type=content_type,
title=camera.name, title=title,
thumbnail=f"/api/camera_proxy/{camera.entity_id}", thumbnail=f"/api/camera_proxy/{camera.entity_id}",
can_play=True, can_play=True,
can_expand=False, can_expand=False,
@ -89,7 +97,7 @@ class CameraMediaSource(MediaSource):
async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None: async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None:
stream_type = camera.frontend_stream_type stream_type = camera.frontend_stream_type
if stream_type is None: if stream_type is None:
return _media_source_for_camera(camera, camera.content_type) return _media_source_for_camera(self.hass, camera, camera.content_type)
if not can_stream_hls: if not can_stream_hls:
return None return None
@ -97,7 +105,7 @@ class CameraMediaSource(MediaSource):
if stream_type != StreamType.HLS and not (await camera.stream_source()): if stream_type != StreamType.HLS and not (await camera.stream_source()):
return None return None
return _media_source_for_camera(camera, content_type) return _media_source_for_camera(self.hass, camera, content_type)
component: EntityComponent[Camera] = self.hass.data[DOMAIN] component: EntityComponent[Camera] = self.hass.data[DOMAIN]
results = await asyncio.gather( results = await asyncio.gather(

View File

@ -6,6 +6,7 @@
"fan_mode": { "fan_mode": {
"default": "mdi:circle-medium", "default": "mdi:circle-medium",
"state": { "state": {
"auto": "mdi:fan-auto",
"diffuse": "mdi:weather-windy", "diffuse": "mdi:weather-windy",
"focus": "mdi:target", "focus": "mdi:target",
"high": "mdi:speedometer", "high": "mdi:speedometer",

View File

@ -7,5 +7,5 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.28"] "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.3.12"]
} }

View File

@ -15,7 +15,7 @@
], ],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"aiodhcpwatcher==0.8.0", "aiodhcpwatcher==0.8.1",
"aiodiscover==1.6.1", "aiodiscover==1.6.1",
"cached_ipaddress==0.3.0" "cached_ipaddress==0.3.0"
] ]

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"requirements": ["pyenphase==1.19.1"], "requirements": ["pyenphase==1.19.2"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20240306.0"] "requirements": ["home-assistant-frontend==20240307.0"]
} }

View File

@ -12,7 +12,6 @@ from gardena_bluetooth.exceptions import (
) )
from gardena_bluetooth.parse import Characteristic, CharacteristicType from gardena_bluetooth.parse import Characteristic, CharacteristicType
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -117,13 +116,7 @@ class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return ( return self.coordinator.last_update_success and self._attr_available
self.coordinator.last_update_success
and bluetooth.async_address_present(
self.hass, self.coordinator.address, True
)
and self._attr_available
)
class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity):

View File

@ -229,11 +229,11 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
available = super().available
sensor_data = getattr(self.coordinator.data, self.entity_description.key) sensor_data = getattr(self.coordinator.data, self.entity_description.key)
available = super().available and bool(sensor_data)
# Sometimes the API returns sensor data without indexes # Sometimes the API returns sensor data without indexes
if self.entity_description.subkey: if self.entity_description.subkey and available:
return available and bool(sensor_data.index) return available and bool(sensor_data.index)
return available and bool(sensor_data) return available

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/calendar.google", "documentation": "https://www.home-assistant.io/integrations/calendar.google",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"], "loggers": ["googleapiclient"],
"requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3", "ical==7.0.0"] "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3", "ical==7.0.1"]
} }

View File

@ -2706,10 +2706,9 @@ class SensorStateTrait(_Trait):
name = TRAIT_SENSOR_STATE name = TRAIT_SENSOR_STATE
commands: list[str] = [] commands: list[str] = []
def _air_quality_description_for_aqi(self, aqi): def _air_quality_description_for_aqi(self, aqi: float | None) -> str:
if aqi is None or aqi.isnumeric() is False: if aqi is None or aqi < 0:
return "unknown" return "unknown"
aqi = int(aqi)
if aqi <= 50: if aqi <= 50:
return "healthy" return "healthy"
if aqi <= 100: if aqi <= 100:
@ -2764,11 +2763,17 @@ class SensorStateTrait(_Trait):
if device_class is None or data is None: if device_class is None or data is None:
return {} return {}
sensor_data = {"name": data[0], "rawValue": self.state.state} try:
value = float(self.state.state)
except ValueError:
value = None
if self.state.state == STATE_UNKNOWN:
value = None
sensor_data = {"name": data[0], "rawValue": value}
if device_class == sensor.SensorDeviceClass.AQI: if device_class == sensor.SensorDeviceClass.AQI:
sensor_data["currentSensorState"] = self._air_quality_description_for_aqi( sensor_data["currentSensorState"] = self._air_quality_description_for_aqi(
self.state.state value
) )
return {"currentSensorStateData": [sensor_data]} return {"currentSensorStateData": [sensor_data]}

View File

@ -19,6 +19,7 @@ ATTR_HOMEASSISTANT = "homeassistant"
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE = "homeassistant_exclude_database" ATTR_HOMEASSISTANT_EXCLUDE_DATABASE = "homeassistant_exclude_database"
ATTR_INPUT = "input" ATTR_INPUT = "input"
ATTR_ISSUES = "issues" ATTR_ISSUES = "issues"
ATTR_MESSAGE = "message"
ATTR_METHOD = "method" ATTR_METHOD = "method"
ATTR_PANELS = "panels" ATTR_PANELS = "panels"
ATTR_PASSWORD = "password" ATTR_PASSWORD = "password"

View File

@ -21,7 +21,7 @@ from homeassistant.const import SERVER_PORT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE from .const import ATTR_DISCOVERY, ATTR_MESSAGE, ATTR_RESULT, DOMAIN, X_HASS_SOURCE
_P = ParamSpec("_P") _P = ParamSpec("_P")
@ -262,10 +262,7 @@ async def async_update_core(
@bind_hass @bind_hass
@_api_bool @_api_bool
async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict: async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict:
"""Apply a suggestion from supervisor's resolution center. """Apply a suggestion from supervisor's resolution center."""
The caller of the function should handle HassioAPIError.
"""
hassio: HassIO = hass.data[DOMAIN] hassio: HassIO = hass.data[DOMAIN]
command = f"/resolution/suggestion/{suggestion_uuid}" command = f"/resolution/suggestion/{suggestion_uuid}"
return await hassio.send_command(command, timeout=None) return await hassio.send_command(command, timeout=None)
@ -576,7 +573,7 @@ class HassIO:
raise HassioAPIError() raise HassioAPIError()
try: try:
request = await self.websession.request( response = await self.websession.request(
method, method,
joined_url, joined_url,
json=payload, json=payload,
@ -589,14 +586,23 @@ class HassIO:
timeout=aiohttp.ClientTimeout(total=timeout), timeout=aiohttp.ClientTimeout(total=timeout),
) )
if request.status != HTTPStatus.OK: if response.status != HTTPStatus.OK:
_LOGGER.error("%s return code %d", command, request.status) error = await response.json(encoding="utf-8")
if error.get(ATTR_RESULT) == "error":
raise HassioAPIError(error.get(ATTR_MESSAGE))
_LOGGER.error(
"Request to %s method %s returned with code %d",
command,
method,
response.status,
)
raise HassioAPIError() raise HassioAPIError()
if return_text: if return_text:
return await request.text(encoding="utf-8") return await response.text(encoding="utf-8")
return await request.json(encoding="utf-8") return await response.json(encoding="utf-8")
except TimeoutError: except TimeoutError:
_LOGGER.error("Timeout on %s request", command) _LOGGER.error("Timeout on %s request", command)

View File

@ -3,11 +3,13 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
import logging import logging
from typing import Any, NotRequired, TypedDict from typing import Any, NotRequired, TypedDict
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HassJob, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import ( from homeassistant.helpers.issue_registry import (
IssueSeverity, IssueSeverity,
async_create_issue, async_create_issue,
@ -35,6 +37,7 @@ from .const import (
EVENT_SUPPORTED_CHANGED, EVENT_SUPPORTED_CHANGED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG, ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_REFERENCE, PLACEHOLDER_KEY_REFERENCE,
REQUEST_REFRESH_DELAY,
UPDATE_KEY_SUPERVISOR, UPDATE_KEY_SUPERVISOR,
SupervisorIssueContext, SupervisorIssueContext,
) )
@ -302,12 +305,17 @@ class SupervisorIssues:
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues
) )
async def update(self) -> None: async def update(self, _: datetime | None = None) -> None:
"""Update issues from Supervisor resolution center.""" """Update issues from Supervisor resolution center."""
try: try:
data = await self._client.get_resolution_info() data = await self._client.get_resolution_info()
except HassioAPIError as err: except HassioAPIError as err:
_LOGGER.error("Failed to update supervisor issues: %r", err) _LOGGER.error("Failed to update supervisor issues: %r", err)
async_call_later(
self._hass,
REQUEST_REFRESH_DELAY,
HassJob(self.update, cancel_on_shutdown=True),
)
return return
self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unhealthy_reasons = set(data[ATTR_UNHEALTHY])
self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED])

View File

@ -18,7 +18,7 @@ from .const import (
PLACEHOLDER_KEY_REFERENCE, PLACEHOLDER_KEY_REFERENCE,
SupervisorIssueContext, SupervisorIssueContext,
) )
from .handler import HassioAPIError, async_apply_suggestion from .handler import async_apply_suggestion
from .issues import Issue, Suggestion from .issues import Issue, Suggestion
SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"}
@ -109,12 +109,9 @@ class SupervisorIssueRepairFlow(RepairsFlow):
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
return self._async_form_for_suggestion(suggestion) return self._async_form_for_suggestion(suggestion)
try: if await async_apply_suggestion(self.hass, suggestion.uuid):
await async_apply_suggestion(self.hass, suggestion.uuid)
except HassioAPIError:
return self.async_abort(reason="apply_suggestion_fail")
return self.async_create_entry(data={}) return self.async_create_entry(data={})
return self.async_abort(reason="apply_suggestion_fail")
@staticmethod @staticmethod
def _async_step( def _async_step(

View File

@ -21,7 +21,6 @@ from .const import (
ATTR_DATA, ATTR_DATA,
ATTR_ENDPOINT, ATTR_ENDPOINT,
ATTR_METHOD, ATTR_METHOD,
ATTR_RESULT,
ATTR_SESSION_DATA_USER_ID, ATTR_SESSION_DATA_USER_ID,
ATTR_TIMEOUT, ATTR_TIMEOUT,
ATTR_WS_EVENT, ATTR_WS_EVENT,
@ -131,9 +130,6 @@ async def websocket_supervisor_api(
payload=payload, payload=payload,
source="core.websocket_api", source="core.websocket_api",
) )
if result.get(ATTR_RESULT) == "error":
raise HassioAPIError(result.get("message"))
except HassioAPIError as err: except HassioAPIError as err:
_LOGGER.error("Failed to to call %s - %s", msg[ATTR_ENDPOINT], err) _LOGGER.error("Failed to to call %s - %s", msg[ATTR_ENDPOINT], err)
connection.send_error( connection.send_error(

View File

@ -57,7 +57,7 @@ async def async_setup_entry(
event_type: EventType, resource: BehaviorInstance | LightLevel | Motion event_type: EventType, resource: BehaviorInstance | LightLevel | Motion
) -> None: ) -> None:
"""Add entity from Hue resource.""" """Add entity from Hue resource."""
async_add_entities([switch_class(bridge, api.sensors.motion, resource)]) async_add_entities([switch_class(bridge, controller, resource)])
# add all current items in controller # add all current items in controller
for item in controller: for item in controller:

View File

@ -269,10 +269,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
self._dynamic_mode_active = lights_in_dynamic_mode > 0 self._dynamic_mode_active = lights_in_dynamic_mode > 0
self._attr_supported_color_modes = supported_color_modes self._attr_supported_color_modes = supported_color_modes
# pick a winner for the current colormode # pick a winner for the current colormode
if ( if lights_with_color_temp_support > 0 and lights_in_colortemp_mode > 0:
lights_with_color_temp_support > 0
and lights_in_colortemp_mode == lights_with_color_temp_support
):
self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_color_mode = ColorMode.COLOR_TEMP
elif lights_with_color_support > 0: elif lights_with_color_support > 0:
self._attr_color_mode = ColorMode.XY self._attr_color_mode = ColorMode.XY

View File

@ -2,7 +2,7 @@
import logging import logging
from typing import Any from typing import Any
from aioautomower.utils import async_structure_token from aioautomower.utils import structure_token
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
@ -27,7 +27,7 @@ class HusqvarnaConfigFlowHandler(
"""Create an entry for the flow.""" """Create an entry for the flow."""
token = data[CONF_TOKEN] token = data[CONF_TOKEN]
user_id = token[CONF_USER_ID] user_id = token[CONF_USER_ID]
structured_token = await async_structure_token(token[CONF_ACCESS_TOKEN]) structured_token = structure_token(token[CONF_ACCESS_TOKEN])
first_name = structured_token.user.first_name first_name = structured_token.user.first_name
last_name = structured_token.user.last_name last_name = structured_token.user.last_name
await self.async_set_unique_id(user_id) await self.async_set_unique_id(user_id)

View File

@ -6,5 +6,6 @@
"dependencies": ["application_credentials"], "dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"requirements": ["aioautomower==2024.2.10"] "loggers": ["aioautomower"],
"requirements": ["aioautomower==2024.3.0"]
} }

View File

@ -52,7 +52,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
is_fixable=False, is_fixable=False,
severity=IssueSeverity.ERROR, severity=IssueSeverity.ERROR,
translation_key="deprecated_yaml_import_issue", translation_key="deprecated_yaml_import_issue",
translation_placeholders={"error_type": error_type}, translation_placeholders={
"error_type": error_type,
"url": "/config/integrations/dashboard/add?domain=hydrawise",
},
) )
return self.async_abort(reason=error_type) return self.async_abort(reason=error_type)

View File

@ -0,0 +1,28 @@
"""Diagnostics support for Internet Printing Protocol (IPP)."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import IPPDataUpdateCoordinator
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
return {
"entry": {
"data": {
**config_entry.data,
},
"unique_id": config_entry.unique_id,
},
"data": coordinator.data.as_dict(),
}

View File

@ -8,6 +8,6 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["deepmerge", "pyipp"], "loggers": ["deepmerge", "pyipp"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyipp==0.14.5"], "requirements": ["pyipp==0.15.0"],
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
} }

View File

@ -149,6 +149,8 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity):
media_content_type = CONTENT_TYPE_MAP.get(self.now_playing["Type"], None) media_content_type = CONTENT_TYPE_MAP.get(self.now_playing["Type"], None)
media_content_id = self.now_playing["Id"] media_content_id = self.now_playing["Id"]
media_title = self.now_playing["Name"] media_title = self.now_playing["Name"]
if "RunTimeTicks" in self.now_playing:
media_duration = int(self.now_playing["RunTimeTicks"] / 10000000) media_duration = int(self.now_playing["RunTimeTicks"] / 10000000)
if media_content_type == MediaType.EPISODE: if media_content_type == MediaType.EPISODE:

View File

@ -1,37 +1,107 @@
"""The JuiceNet integration.""" """The JuiceNet integration."""
from __future__ import annotations from datetime import timedelta
import logging
from homeassistant.config_entries import ConfigEntry, ConfigEntryState import aiohttp
from pyjuicenet import Api, TokenError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
DOMAIN = "juicenet" from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .device import JuiceNetApi
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
CONFIG_SCHEMA = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},
),
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the JuiceNet component."""
conf = config.get(DOMAIN)
hass.data.setdefault(DOMAIN, {})
if not conf:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up JuiceNet from a config entry.""" """Set up JuiceNet from a config entry."""
ir.async_create_issue(
config = entry.data
session = async_get_clientsession(hass)
access_token = config[CONF_ACCESS_TOKEN]
api = Api(access_token, session)
juicenet = JuiceNetApi(api)
try:
await juicenet.setup()
except TokenError as error:
_LOGGER.error("JuiceNet Error %s", error)
return False
except aiohttp.ClientError as error:
_LOGGER.error("Could not reach the JuiceNet API %s", error)
raise ConfigEntryNotReady from error
if not juicenet.devices:
_LOGGER.error("No JuiceNet devices found for this account")
return False
_LOGGER.info("%d JuiceNet device(s) found", len(juicenet.devices))
async def async_update_data():
"""Update all device states from the JuiceNet API."""
for device in juicenet.devices:
await device.update_state(True)
return True
coordinator = DataUpdateCoordinator(
hass, hass,
DOMAIN, _LOGGER,
DOMAIN, name="JuiceNet",
is_fixable=False, update_method=async_update_data,
severity=ir.IssueSeverity.ERROR, update_interval=timedelta(seconds=30),
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/juicenet",
},
) )
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = {
JUICENET_API: juicenet,
JUICENET_COORDINATOR: coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if all( unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
config_entry.state is ConfigEntryState.NOT_LOADED if unload_ok:
for config_entry in hass.config_entries.async_entries(DOMAIN) hass.data[DOMAIN].pop(entry.entry_id)
if config_entry.entry_id != entry.entry_id return unload_ok
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
return True

View File

@ -1,11 +1,77 @@
"""Config flow for JuiceNet integration.""" """Config flow for JuiceNet integration."""
import logging
from homeassistant import config_entries import aiohttp
from pyjuicenet import Api, TokenError
import voluptuous as vol
from . import DOMAIN from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
juicenet = Api(data[CONF_ACCESS_TOKEN], session)
try:
await juicenet.get_devices()
except TokenError as error:
_LOGGER.error("Token Error %s", error)
raise InvalidAuth from error
except aiohttp.ClientError as error:
_LOGGER.error("Error connecting %s", error)
raise CannotConnect from error
# Return info that you want to store in the config entry.
return {"title": "JuiceNet"}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for JuiceNet.""" """Handle a config flow for JuiceNet."""
VERSION = 1 VERSION = 1
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN])
self._abort_if_unique_id_configured()
try:
info = await validate_input(self.hass, user_input)
return self.async_create_entry(title=info["title"], data=user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(self, user_input):
"""Handle import."""
return await self.async_step_user(user_input)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -0,0 +1,6 @@
"""Constants used by the JuiceNet component."""
DOMAIN = "juicenet"
JUICENET_API = "juicenet_api"
JUICENET_COORDINATOR = "juicenet_coordinator"

View File

@ -0,0 +1,19 @@
"""Adapter to wrap the pyjuicenet api for home assistant."""
class JuiceNetApi:
"""Represent a connection to JuiceNet."""
def __init__(self, api):
"""Create an object from the provided API instance."""
self.api = api
self._devices = []
async def setup(self):
"""JuiceNet device setup.""" # noqa: D403
self._devices = await self.api.get_devices()
@property
def devices(self) -> list:
"""Get a list of devices managed by this account."""
return self._devices

View File

@ -0,0 +1,34 @@
"""Adapter to wrap the pyjuicenet api for home assistant."""
from pyjuicenet import Charger
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN
class JuiceNetDevice(CoordinatorEntity):
"""Represent a base JuiceNet device."""
_attr_has_entity_name = True
def __init__(
self, device: Charger, key: str, coordinator: DataUpdateCoordinator
) -> None:
"""Initialise the sensor."""
super().__init__(coordinator)
self.device = device
self.key = key
self._attr_unique_id = f"{device.id}-{key}"
self._attr_device_info = DeviceInfo(
configuration_url=(
f"https://home.juice.net/Portal/Details?unitID={device.id}"
),
identifiers={(DOMAIN, device.id)},
manufacturer="JuiceNet",
name=device.name,
)

View File

@ -1,9 +1,10 @@
{ {
"domain": "juicenet", "domain": "juicenet",
"name": "JuiceNet", "name": "JuiceNet",
"codeowners": [], "codeowners": ["@jesserockz"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/juicenet", "documentation": "https://www.home-assistant.io/integrations/juicenet",
"integration_type": "system",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": [] "loggers": ["pyjuicenet"],
"requirements": ["python-juicenet==1.1.0"]
} }

View File

@ -0,0 +1,99 @@
"""Support for controlling juicenet/juicepoint/juicebox based EVSE numbers."""
from __future__ import annotations
from dataclasses import dataclass
from pyjuicenet import Api, Charger
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .entity import JuiceNetDevice
@dataclass(frozen=True)
class JuiceNetNumberEntityDescriptionMixin:
"""Mixin for required keys."""
setter_key: str
@dataclass(frozen=True)
class JuiceNetNumberEntityDescription(
NumberEntityDescription, JuiceNetNumberEntityDescriptionMixin
):
"""An entity description for a JuiceNetNumber."""
native_max_value_key: str | None = None
NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = (
JuiceNetNumberEntityDescription(
translation_key="amperage_limit",
key="current_charging_amperage_limit",
native_min_value=6,
native_max_value_key="max_charging_amperage",
native_step=1,
setter_key="set_charging_amperage_limit",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the JuiceNet Numbers."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
api: Api = juicenet_data[JUICENET_API]
coordinator = juicenet_data[JUICENET_COORDINATOR]
entities = [
JuiceNetNumber(device, description, coordinator)
for device in api.devices
for description in NUMBER_TYPES
]
async_add_entities(entities)
class JuiceNetNumber(JuiceNetDevice, NumberEntity):
"""Implementation of a JuiceNet number."""
entity_description: JuiceNetNumberEntityDescription
def __init__(
self,
device: Charger,
description: JuiceNetNumberEntityDescription,
coordinator: DataUpdateCoordinator,
) -> None:
"""Initialise the number."""
super().__init__(device, description.key, coordinator)
self.entity_description = description
@property
def native_value(self) -> float | None:
"""Return the value of the entity."""
return getattr(self.device, self.entity_description.key, None)
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
if self.entity_description.native_max_value_key is not None:
return getattr(self.device, self.entity_description.native_max_value_key)
if self.entity_description.native_max_value is not None:
return self.entity_description.native_max_value
return DEFAULT_MAX_VALUE
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
await getattr(self.device, self.entity_description.setter_key)(value)

View File

@ -0,0 +1,116 @@
"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors."""
from __future__ import annotations
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .entity import JuiceNetDevice
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="status",
name="Charging Status",
),
SensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
SensorEntityDescription(
key="amps",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="watts",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charge_time",
translation_key="charge_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:timer-outline",
),
SensorEntityDescription(
key="energy_added",
translation_key="energy_added",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the JuiceNet Sensors."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
api = juicenet_data[JUICENET_API]
coordinator = juicenet_data[JUICENET_COORDINATOR]
entities = [
JuiceNetSensorDevice(device, coordinator, description)
for device in api.devices
for description in SENSOR_TYPES
]
async_add_entities(entities)
class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity):
"""Implementation of a JuiceNet sensor."""
def __init__(
self, device, coordinator, description: SensorEntityDescription
) -> None:
"""Initialise the sensor."""
super().__init__(device, description.key, coordinator)
self.entity_description = description
@property
def icon(self):
"""Return the icon of the sensor."""
icon = None
if self.entity_description.key == "status":
status = self.device.status
if status == "standby":
icon = "mdi:power-plug-off"
elif status == "plugged":
icon = "mdi:power-plug"
elif status == "charging":
icon = "mdi:battery-positive"
else:
icon = self.entity_description.icon
return icon
@property
def native_value(self):
"""Return the state."""
return getattr(self.device, self.entity_description.key, None)

View File

@ -1,8 +1,41 @@
{ {
"issues": { "config": {
"integration_removed": { "abort": {
"title": "The JuiceNet integration has been removed", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"description": "Enel X has dropped support for JuiceNet in favor of JuicePass, and the JuiceNet integration has been removed from Home Assistant as it was no longer working.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing JuiceNet integration entries]({entries})." },
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"description": "You will need the API Token from https://home.juice.net/Manage.",
"title": "Connect to JuiceNet"
}
}
},
"entity": {
"number": {
"amperage_limit": {
"name": "Amperage limit"
}
},
"sensor": {
"charge_time": {
"name": "Charge time"
},
"energy_added": {
"name": "Energy added"
}
},
"switch": {
"charge_now": {
"name": "Charge now"
}
} }
} }
} }

View File

@ -0,0 +1,49 @@
"""Support for monitoring juicenet/juicepoint/juicebox based EVSE switches."""
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .entity import JuiceNetDevice
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the JuiceNet switches."""
entities = []
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
api = juicenet_data[JUICENET_API]
coordinator = juicenet_data[JUICENET_COORDINATOR]
for device in api.devices:
entities.append(JuiceNetChargeNowSwitch(device, coordinator))
async_add_entities(entities)
class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity):
"""Implementation of a JuiceNet switch."""
_attr_translation_key = "charge_now"
def __init__(self, device, coordinator):
"""Initialise the switch."""
super().__init__(device, "charge_now", coordinator)
@property
def is_on(self):
"""Return true if switch is on."""
return self.device.override_time != 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Charge now."""
await self.device.set_override(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Don't charge now."""
await self.device.set_override(False)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar", "documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["ical"], "loggers": ["ical"],
"requirements": ["ical==7.0.0"] "requirements": ["ical==7.0.1"]
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo", "documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["ical==7.0.0"] "requirements": ["ical==7.0.1"]
} }

View File

@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CONF_TODO_LIST_NAME, DOMAIN from .const import CONF_TODO_LIST_NAME, DOMAIN
from .store import LocalTodoListStore from .store import LocalTodoListStore
@ -124,6 +125,9 @@ class LocalTodoListEntity(TodoListEntity):
self._attr_name = name.capitalize() self._attr_name = name.capitalize()
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
def _new_todo_store(self) -> TodoStore:
return TodoStore(self._calendar, tzinfo=dt_util.DEFAULT_TIME_ZONE)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update entity state based on the local To-do items.""" """Update entity state based on the local To-do items."""
todo_items = [] todo_items = []
@ -147,20 +151,20 @@ class LocalTodoListEntity(TodoListEntity):
async def async_create_todo_item(self, item: TodoItem) -> None: async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list.""" """Add an item to the To-do list."""
todo = _convert_item(item) todo = _convert_item(item)
TodoStore(self._calendar).add(todo) self._new_todo_store().add(todo)
await self.async_save() await self.async_save()
await self.async_update_ha_state(force_refresh=True) await self.async_update_ha_state(force_refresh=True)
async def async_update_todo_item(self, item: TodoItem) -> None: async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item to the To-do list.""" """Update an item to the To-do list."""
todo = _convert_item(item) todo = _convert_item(item)
TodoStore(self._calendar).edit(todo.uid, todo) self._new_todo_store().edit(todo.uid, todo)
await self.async_save() await self.async_save()
await self.async_update_ha_state(force_refresh=True) await self.async_update_ha_state(force_refresh=True)
async def async_delete_todo_items(self, uids: list[str]) -> None: async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete an item from the To-do list.""" """Delete an item from the To-do list."""
store = TodoStore(self._calendar) store = self._new_todo_store()
for uid in uids: for uid in uids:
store.delete(uid) store.delete(uid)
await self.async_save() await self.async_save()

View File

@ -7,7 +7,7 @@
"dependencies": ["webhook"], "dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/loqed", "documentation": "https://www.home-assistant.io/integrations/loqed",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["loqedAPI==2.1.8"], "requirements": ["loqedAPI==2.1.10"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_http._tcp.local.", "type": "_http._tcp.local.",

View File

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/luci", "documentation": "https://www.home-assistant.io/integrations/luci",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["openwrt_luci_rpc"], "loggers": ["openwrt_luci_rpc"],
"requirements": ["openwrt-luci-rpc==1.1.16"] "requirements": ["openwrt-luci-rpc==1.1.17"]
} }

View File

@ -134,12 +134,11 @@ class MjpegCamera(Camera):
self, width: int | None = None, height: int | None = None self, width: int | None = None, height: int | None = None
) -> bytes | None: ) -> bytes | None:
"""Return a still image response from the camera.""" """Return a still image response from the camera."""
# DigestAuth is not supported
if ( if (
self._authentication == HTTP_DIGEST_AUTHENTICATION self._authentication == HTTP_DIGEST_AUTHENTICATION
or self._still_image_url is None or self._still_image_url is None
): ):
return await self._async_digest_camera_image() return await self._async_digest_or_fallback_camera_image()
websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl)
try: try:
@ -157,15 +156,17 @@ class MjpegCamera(Camera):
return None return None
def _get_digest_auth(self) -> httpx.DigestAuth: def _get_httpx_auth(self) -> httpx.Auth:
"""Return a DigestAuth object.""" """Return a httpx auth object."""
username = "" if self._username is None else self._username username = "" if self._username is None else self._username
return httpx.DigestAuth(username, self._password) digest_auth = self._authentication == HTTP_DIGEST_AUTHENTICATION
cls = httpx.DigestAuth if digest_auth else httpx.BasicAuth
return cls(username, self._password)
async def _async_digest_camera_image(self) -> bytes | None: async def _async_digest_or_fallback_camera_image(self) -> bytes | None:
"""Return a still image response from the camera using digest authentication.""" """Return a still image response from the camera using digest authentication."""
client = get_async_client(self.hass, verify_ssl=self._verify_ssl) client = get_async_client(self.hass, verify_ssl=self._verify_ssl)
auth = self._get_digest_auth() auth = self._get_httpx_auth()
try: try:
if self._still_image_url: if self._still_image_url:
# Fallback to MJPEG stream if still image URL is not available # Fallback to MJPEG stream if still image URL is not available
@ -196,7 +197,7 @@ class MjpegCamera(Camera):
) -> web.StreamResponse | None: ) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera using digest authentication.""" """Generate an HTTP MJPEG stream from the camera using digest authentication."""
async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream( async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream(
"get", self._mjpeg_url, auth=self._get_digest_auth(), timeout=TIMEOUT "get", self._mjpeg_url, auth=self._get_httpx_auth(), timeout=TIMEOUT
) as stream: ) as stream:
response = web.StreamResponse(headers=stream.headers) response = web.StreamResponse(headers=stream.headers)
await response.prepare(request) await response.prepare(request)

View File

@ -6,5 +6,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pymodbus"], "loggers": ["pymodbus"],
"quality_scale": "gold", "quality_scale": "gold",
"requirements": ["pymodbus==3.6.4"] "requirements": ["pymodbus==3.6.5"]
} }

View File

@ -301,16 +301,17 @@ def check_config(config: dict) -> dict:
def validate_entity( def validate_entity(
hub_name: str, hub_name: str,
component: str,
entity: dict, entity: dict,
minimum_scan_interval: int, minimum_scan_interval: int,
ent_names: set, ent_names: set,
ent_addr: set, ent_addr: set,
) -> bool: ) -> bool:
"""Validate entity.""" """Validate entity."""
name = entity[CONF_NAME] name = f"{component}.{entity[CONF_NAME]}"
addr = f"{hub_name}{entity[CONF_ADDRESS]}" addr = f"{hub_name}{entity[CONF_ADDRESS]}"
scan_interval = entity.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) scan_interval = entity.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
if scan_interval < 5: if 0 < scan_interval < 5:
_LOGGER.warning( _LOGGER.warning(
( (
"%s %s scan_interval(%d) is lower than 5 seconds, " "%s %s scan_interval(%d) is lower than 5 seconds, "
@ -368,15 +369,18 @@ def check_config(config: dict) -> dict:
if not validate_modbus(hub, hub_name_inx): if not validate_modbus(hub, hub_name_inx):
del config[hub_inx] del config[hub_inx]
continue continue
for _component, conf_key in PLATFORMS: minimum_scan_interval = 9999
no_entities = True
for component, conf_key in PLATFORMS:
if conf_key not in hub: if conf_key not in hub:
continue continue
no_entities = False
entity_inx = 0 entity_inx = 0
entities = hub[conf_key] entities = hub[conf_key]
minimum_scan_interval = 9999
while entity_inx < len(entities): while entity_inx < len(entities):
if not validate_entity( if not validate_entity(
hub[CONF_NAME], hub[CONF_NAME],
component,
entities[entity_inx], entities[entity_inx],
minimum_scan_interval, minimum_scan_interval,
ent_names, ent_names,
@ -385,7 +389,11 @@ def check_config(config: dict) -> dict:
del entities[entity_inx] del entities[entity_inx]
else: else:
entity_inx += 1 entity_inx += 1
if no_entities:
err = f"Modbus {hub[CONF_NAME]} contain no entities, this will cause instability, please add at least one entity!"
_LOGGER.warning(err)
# Ensure timeout is not started/handled.
hub[CONF_TIMEOUT] = -1
if hub[CONF_TIMEOUT] >= minimum_scan_interval: if hub[CONF_TIMEOUT] >= minimum_scan_interval:
hub[CONF_TIMEOUT] = minimum_scan_interval - 1 hub[CONF_TIMEOUT] = minimum_scan_interval - 1
_LOGGER.warning( _LOGGER.warning(

View File

@ -180,6 +180,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Create a callback to save the refresh token when it changes: # Create a callback to save the refresh token when it changes:
entry.async_on_unload(client.add_refresh_token_callback(async_save_refresh_token)) entry.async_on_unload(client.add_refresh_token_callback(async_save_refresh_token))
# Save the client's refresh token if it's different than what we already have:
if (token := client.refresh_token) and token != entry.data[CONF_REFRESH_TOKEN]:
async_save_refresh_token(token)
hass.config_entries.async_update_entry(entry, **entry_updates) hass.config_entries.async_update_entry(entry, **entry_updates)
async def async_update() -> NotionData: async def async_update() -> NotionData:

View File

@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aionotion"], "loggers": ["aionotion"],
"requirements": ["aionotion==2024.02.2"] "requirements": ["aionotion==2024.03.0"]
} }

View File

@ -3,7 +3,8 @@
"name": "Numato USB GPIO Expander", "name": "Numato USB GPIO Expander",
"codeowners": ["@clssn"], "codeowners": ["@clssn"],
"documentation": "https://www.home-assistant.io/integrations/numato", "documentation": "https://www.home-assistant.io/integrations/numato",
"integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["numato_gpio"], "loggers": ["numato_gpio"],
"requirements": ["numato-gpio==0.10.0"] "requirements": ["numato-gpio==0.12.0"]
} }

View File

@ -132,16 +132,27 @@ class RAVEnDataCoordinator(DataUpdateCoordinator):
) )
return None return None
async def async_shutdown(self) -> None:
"""Shutdown the coordinator."""
await self._cleanup_device()
await super().async_shutdown()
async def _async_update_data(self) -> dict[str, Any]: async def _async_update_data(self) -> dict[str, Any]:
try: try:
device = await self._get_device() device = await self._get_device()
async with asyncio.timeout(5): async with asyncio.timeout(5):
return await _get_all_data(device, self.entry.data[CONF_MAC]) return await _get_all_data(device, self.entry.data[CONF_MAC])
except RAVEnConnectionError as err: except RAVEnConnectionError as err:
if self._raven_device: await self._cleanup_device()
await self._raven_device.close()
self._raven_device = None
raise UpdateFailed(f"RAVEnConnectionError: {err}") from err raise UpdateFailed(f"RAVEnConnectionError: {err}") from err
except TimeoutError:
await self._cleanup_device()
raise
async def _cleanup_device(self) -> None:
device, self._raven_device = self._raven_device, None
if device is not None:
await device.close()
async def _get_device(self) -> RAVEnSerialDevice: async def _get_device(self) -> RAVEnSerialDevice:
if self._raven_device is not None: if self._raven_device is not None:
@ -149,13 +160,12 @@ class RAVEnDataCoordinator(DataUpdateCoordinator):
device = RAVEnSerialDevice(self.entry.data[CONF_DEVICE]) device = RAVEnSerialDevice(self.entry.data[CONF_DEVICE])
try:
async with asyncio.timeout(5): async with asyncio.timeout(5):
await device.open() await device.open()
try:
await device.synchronize() await device.synchronize()
self._device_info = await device.get_device_info() self._device_info = await device.get_device_info()
except Exception: except:
await device.close() await device.close()
raise raise

View File

@ -924,7 +924,7 @@ class Recorder(threading.Thread):
# that is pending before running the task # that is pending before running the task
if TYPE_CHECKING: if TYPE_CHECKING:
assert isinstance(task, RecorderTask) assert isinstance(task, RecorderTask)
if not task.commit_before: if task.commit_before:
self._commit_event_session_or_retry() self._commit_event_session_or_retry()
return task.run(self) return task.run(self)
except exc.DatabaseError as err: except exc.DatabaseError as err:

View File

@ -6,7 +6,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.start import async_at_start from homeassistant.helpers.start import async_at_start
from .core import Recorder from .core import Recorder
from .util import get_instance, session_scope from .util import filter_unique_constraint_integrity_error, get_instance, session_scope
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -61,7 +61,10 @@ def update_states_metadata(
) )
return return
with session_scope(session=instance.get_session()) as session: with session_scope(
session=instance.get_session(),
exception_filter=filter_unique_constraint_integrity_error(instance, "state"),
) as session:
if not states_meta_manager.update_metadata(session, entity_id, new_entity_id): if not states_meta_manager.update_metadata(session, entity_id, new_entity_id):
_LOGGER.warning( _LOGGER.warning(
"Cannot migrate history for entity_id `%s` to `%s` " "Cannot migrate history for entity_id `%s` to `%s` "

View File

@ -3,7 +3,6 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
import contextlib
import dataclasses import dataclasses
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import lru_cache, partial from functools import lru_cache, partial
@ -15,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text
from sqlalchemy.engine.row import Row from sqlalchemy.engine.row import Row
from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.lambdas import StatementLambdaElement
import voluptuous as vol import voluptuous as vol
@ -72,6 +71,7 @@ from .models import (
from .util import ( from .util import (
execute, execute,
execute_stmt_lambda_element, execute_stmt_lambda_element,
filter_unique_constraint_integrity_error,
get_instance, get_instance,
retryable_database_job, retryable_database_job,
session_scope, session_scope,
@ -454,7 +454,9 @@ def compile_missing_statistics(instance: Recorder) -> bool:
with session_scope( with session_scope(
session=instance.get_session(), session=instance.get_session(),
exception_filter=_filter_unique_constraint_integrity_error(instance), exception_filter=filter_unique_constraint_integrity_error(
instance, "statistic"
),
) as session: ) as session:
# Find the newest statistics run, if any # Find the newest statistics run, if any
if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): if last_run := session.query(func.max(StatisticsRuns.start)).scalar():
@ -486,7 +488,9 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) -
# Return if we already have 5-minute statistics for the requested period # Return if we already have 5-minute statistics for the requested period
with session_scope( with session_scope(
session=instance.get_session(), session=instance.get_session(),
exception_filter=_filter_unique_constraint_integrity_error(instance), exception_filter=filter_unique_constraint_integrity_error(
instance, "statistic"
),
) as session: ) as session:
modified_statistic_ids = _compile_statistics( modified_statistic_ids = _compile_statistics(
instance, session, start, fire_events instance, session, start, fire_events
@ -737,7 +741,9 @@ def update_statistics_metadata(
if new_statistic_id is not UNDEFINED and new_statistic_id is not None: if new_statistic_id is not UNDEFINED and new_statistic_id is not None:
with session_scope( with session_scope(
session=instance.get_session(), session=instance.get_session(),
exception_filter=_filter_unique_constraint_integrity_error(instance), exception_filter=filter_unique_constraint_integrity_error(
instance, "statistic"
),
) as session: ) as session:
statistics_meta_manager.update_statistic_id( statistics_meta_manager.update_statistic_id(
session, DOMAIN, statistic_id, new_statistic_id session, DOMAIN, statistic_id, new_statistic_id
@ -2246,54 +2252,6 @@ def async_add_external_statistics(
_async_import_statistics(hass, metadata, statistics) _async_import_statistics(hass, metadata, statistics)
def _filter_unique_constraint_integrity_error(
instance: Recorder,
) -> Callable[[Exception], bool]:
def _filter_unique_constraint_integrity_error(err: Exception) -> bool:
"""Handle unique constraint integrity errors."""
if not isinstance(err, StatementError):
return False
assert instance.engine is not None
dialect_name = instance.engine.dialect.name
ignore = False
if (
dialect_name == SupportedDialect.SQLITE
and "UNIQUE constraint failed" in str(err)
):
ignore = True
if (
dialect_name == SupportedDialect.POSTGRESQL
and err.orig
and hasattr(err.orig, "pgcode")
and err.orig.pgcode == "23505"
):
ignore = True
if (
dialect_name == SupportedDialect.MYSQL
and err.orig
and hasattr(err.orig, "args")
):
with contextlib.suppress(TypeError):
if err.orig.args[0] == 1062:
ignore = True
if ignore:
_LOGGER.warning(
(
"Blocked attempt to insert duplicated statistic rows, please report"
" at %s"
),
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22",
exc_info=err,
)
return ignore
return _filter_unique_constraint_integrity_error
def _import_statistics_with_session( def _import_statistics_with_session(
instance: Recorder, instance: Recorder,
session: Session, session: Session,
@ -2397,7 +2355,9 @@ def import_statistics(
with session_scope( with session_scope(
session=instance.get_session(), session=instance.get_session(),
exception_filter=_filter_unique_constraint_integrity_error(instance), exception_filter=filter_unique_constraint_integrity_error(
instance, "statistic"
),
) as session: ) as session:
return _import_statistics_with_session( return _import_statistics_with_session(
instance, session, metadata, statistics, table instance, session, metadata, statistics, table

View File

@ -307,11 +307,18 @@ class StatisticsMetaManager:
recorder thread. recorder thread.
""" """
self._assert_in_recorder_thread() self._assert_in_recorder_thread()
if self.get(session, new_statistic_id):
_LOGGER.error(
"Cannot rename statistic_id `%s` to `%s` because the new statistic_id is already in use",
old_statistic_id,
new_statistic_id,
)
return
session.query(StatisticsMeta).filter( session.query(StatisticsMeta).filter(
(StatisticsMeta.statistic_id == old_statistic_id) (StatisticsMeta.statistic_id == old_statistic_id)
& (StatisticsMeta.source == source) & (StatisticsMeta.source == source)
).update({StatisticsMeta.statistic_id: new_statistic_id}) ).update({StatisticsMeta.statistic_id: new_statistic_id})
self._clear_cache([old_statistic_id, new_statistic_id]) self._clear_cache([old_statistic_id])
def delete(self, session: Session, statistic_ids: list[str]) -> None: def delete(self, session: Session, statistic_ids: list[str]) -> None:
"""Clear statistics for a list of statistic_ids. """Clear statistics for a list of statistic_ids.

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Collection, Generator, Iterable, Sequence from collections.abc import Callable, Collection, Generator, Iterable, Sequence
import contextlib
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import functools import functools
@ -21,7 +22,7 @@ import ciso8601
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from sqlalchemy.engine import Result, Row from sqlalchemy.engine import Result, Row
from sqlalchemy.engine.interfaces import DBAPIConnection from sqlalchemy.engine.interfaces import DBAPIConnection
from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError
from sqlalchemy.orm.query import Query from sqlalchemy.orm.query import Query
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.lambdas import StatementLambdaElement
@ -906,3 +907,54 @@ def get_index_by_name(session: Session, table_name: str, index_name: str) -> str
), ),
None, None,
) )
def filter_unique_constraint_integrity_error(
instance: Recorder, row_type: str
) -> Callable[[Exception], bool]:
"""Create a filter for unique constraint integrity errors."""
def _filter_unique_constraint_integrity_error(err: Exception) -> bool:
"""Handle unique constraint integrity errors."""
if not isinstance(err, StatementError):
return False
assert instance.engine is not None
dialect_name = instance.engine.dialect.name
ignore = False
if (
dialect_name == SupportedDialect.SQLITE
and "UNIQUE constraint failed" in str(err)
):
ignore = True
if (
dialect_name == SupportedDialect.POSTGRESQL
and err.orig
and hasattr(err.orig, "pgcode")
and err.orig.pgcode == "23505"
):
ignore = True
if (
dialect_name == SupportedDialect.MYSQL
and err.orig
and hasattr(err.orig, "args")
):
with contextlib.suppress(TypeError):
if err.orig.args[0] == 1062:
ignore = True
if ignore:
_LOGGER.warning(
(
"Blocked attempt to insert duplicated %s rows, please report"
" at %s"
),
row_type,
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22",
exc_info=err,
)
return ignore
return _filter_unique_constraint_integrity_error

View File

@ -11,7 +11,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["rokuecp"], "loggers": ["rokuecp"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["rokuecp==0.19.1"], "requirements": ["rokuecp==0.19.2"],
"ssdp": [ "ssdp": [
{ {
"st": "roku:ecp", "st": "roku:ecp",

View File

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/rova", "documentation": "https://www.home-assistant.io/integrations/rova",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["rova"], "loggers": ["rova"],
"requirements": ["rova==0.4.0"] "requirements": ["rova==0.4.1"]
} }

View File

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/snmp", "documentation": "https://www.home-assistant.io/integrations/snmp",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyasn1", "pysmi", "pysnmp"], "loggers": ["pyasn1", "pysmi", "pysnmp"],
"requirements": ["pysnmp-lextudio==6.0.2"] "requirements": ["pysnmp-lextudio==6.0.9"]
} }

View File

@ -237,7 +237,7 @@ class SnmpSwitch(SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch.""" """Turn on the switch."""
# If vartype set, use it - http://snmplabs.com/pysnmp/docs/api-reference.html#pysnmp.smi.rfc1902.ObjectType # If vartype set, use it - https://www.pysnmp.com/pysnmp/docs/api-reference.html#pysnmp.smi.rfc1902.ObjectType
await self._execute_command(self._command_payload_on) await self._execute_command(self._command_payload_on)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:

View File

@ -401,9 +401,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
def set_timer( def set_timer(
self, self,
temperature: float, temperature: float | None = None,
time_period: int, time_period: int | None = None,
requested_overlay: str, requested_overlay: str | None = None,
): ):
"""Set the timer on the entity, and temperature if supported.""" """Set the timer on the entity, and temperature if supported."""

View File

@ -6,5 +6,6 @@
"dependencies": ["http"], "dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/tedee", "documentation": "https://www.home-assistant.io/integrations/tedee",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["pytedee-async==0.2.15"] "loggers": ["pytedee_async"],
"requirements": ["pytedee-async==0.2.16"]
} }

View File

@ -16,7 +16,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
async_get_config_entry_implementation, async_get_config_entry_implementation,
) )
from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS from .const import CLIENT, DOMAIN, OAUTH_SCOPES, PLATFORMS, SESSION
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -45,7 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client.auto_refresh_auth = False client.auto_refresh_auth = False
await client.set_user_authentication(access_token, scope=OAUTH_SCOPES) await client.set_user_authentication(access_token, scope=OAUTH_SCOPES)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
CLIENT: client,
SESSION: session,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -16,5 +16,7 @@ CONF_REFRESH_TOKEN = "refresh_token"
DOMAIN = "twitch" DOMAIN = "twitch"
CONF_CHANNELS = "channels" CONF_CHANNELS = "channels"
CLIENT = "client"
SESSION = "session"
OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS, AuthScope.USER_READ_FOLLOWS] OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS, AuthScope.USER_READ_FOLLOWS]

View File

@ -19,12 +19,13 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES from .const import CLIENT, CONF_CHANNELS, DOMAIN, LOGGER, OAUTH_SCOPES, SESSION
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
@ -51,6 +52,8 @@ ICON = "mdi:twitch"
STATE_OFFLINE = "offline" STATE_OFFLINE = "offline"
STATE_STREAMING = "streaming" STATE_STREAMING = "streaming"
PARALLEL_UPDATES = 1
def chunk_list(lst: list, chunk_size: int) -> list[list]: def chunk_list(lst: list, chunk_size: int) -> list[list]:
"""Split a list into chunks of chunk_size.""" """Split a list into chunks of chunk_size."""
@ -97,7 +100,8 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Initialize entries.""" """Initialize entries."""
client = hass.data[DOMAIN][entry.entry_id] client = hass.data[DOMAIN][entry.entry_id][CLIENT]
session = hass.data[DOMAIN][entry.entry_id][SESSION]
channels = entry.options[CONF_CHANNELS] channels = entry.options[CONF_CHANNELS]
@ -107,7 +111,7 @@ async def async_setup_entry(
for chunk in chunk_list(channels, 100): for chunk in chunk_list(channels, 100):
entities.extend( entities.extend(
[ [
TwitchSensor(channel, client) TwitchSensor(channel, session, client)
async for channel in client.get_users(logins=chunk) async for channel in client.get_users(logins=chunk)
] ]
) )
@ -120,8 +124,11 @@ class TwitchSensor(SensorEntity):
_attr_icon = ICON _attr_icon = ICON
def __init__(self, channel: TwitchUser, client: Twitch) -> None: def __init__(
self, channel: TwitchUser, session: OAuth2Session, client: Twitch
) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self._session = session
self._client = client self._client = client
self._channel = channel self._channel = channel
self._enable_user_auth = client.has_required_auth(AuthType.USER, OAUTH_SCOPES) self._enable_user_auth = client.has_required_auth(AuthType.USER, OAUTH_SCOPES)
@ -130,9 +137,17 @@ class TwitchSensor(SensorEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update device state.""" """Update device state."""
followers = (await self._client.get_channel_followers(self._channel.id)).total await self._session.async_ensure_token_valid()
await self._client.set_user_authentication(
self._session.token["access_token"],
OAUTH_SCOPES,
self._session.token["refresh_token"],
False,
)
followers = await self._client.get_channel_followers(self._channel.id)
self._attr_extra_state_attributes = { self._attr_extra_state_attributes = {
ATTR_FOLLOWING: followers, ATTR_FOLLOWING: followers.total,
ATTR_VIEWS: self._channel.view_count, ATTR_VIEWS: self._channel.view_count,
} }
if self._enable_user_auth: if self._enable_user_auth:
@ -166,7 +181,7 @@ class TwitchSensor(SensorEntity):
self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True
self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = sub.is_gift self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = sub.is_gift
except TwitchResourceNotFound: except TwitchResourceNotFound:
LOGGER.debug("User is not subscribed") LOGGER.debug("User is not subscribed to %s", self._channel.display_name)
except TwitchAPIException as exc: except TwitchAPIException as exc:
LOGGER.error("Error response on check_user_subscription: %s", exc) LOGGER.error("Error response on check_user_subscription: %s", exc)

View File

@ -79,6 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Create device. # Create device.
assert discovery_info is not None assert discovery_info is not None
assert discovery_info.ssdp_udn
assert discovery_info.ssdp_all_locations assert discovery_info.ssdp_all_locations
location = get_preferred_location(discovery_info.ssdp_all_locations) location = get_preferred_location(discovery_info.ssdp_all_locations)
try: try:
@ -117,7 +118,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if device.serial_number: if device.serial_number:
identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number))
connections = {(dr.CONNECTION_UPNP, device.udn)} connections = {(dr.CONNECTION_UPNP, discovery_info.ssdp_udn)}
if discovery_info.ssdp_udn != device.udn:
connections.add((dr.CONNECTION_UPNP, device.udn))
if device_mac_address: if device_mac_address:
connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address)) connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address))

View File

@ -42,7 +42,7 @@ def _friendly_name_from_discovery(discovery_info: ssdp.SsdpServiceInfo) -> str:
def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool:
"""Test if discovery is complete and usable.""" """Test if discovery is complete and usable."""
return bool( return bool(
ssdp.ATTR_UPNP_UDN in discovery_info.upnp discovery_info.ssdp_udn
and discovery_info.ssdp_st and discovery_info.ssdp_st
and discovery_info.ssdp_all_locations and discovery_info.ssdp_all_locations
and discovery_info.ssdp_usn and discovery_info.ssdp_usn
@ -80,9 +80,8 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
# Paths: # Paths:
# - ssdp(discovery_info) --> ssdp_confirm(None) # 1: ssdp(discovery_info) --> ssdp_confirm(None) --> ssdp_confirm({}) --> create_entry()
# --> ssdp_confirm({}) --> create_entry() # 2: user(None): scan --> user({...}) --> create_entry()
# - user(None): scan --> user({...}) --> create_entry()
@property @property
def _discoveries(self) -> dict[str, SsdpServiceInfo]: def _discoveries(self) -> dict[str, SsdpServiceInfo]:
@ -241,9 +240,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
discovery = self._remove_discovery(usn) discovery = self._remove_discovery(usn)
mac_address = await _async_mac_address_from_discovery(self.hass, discovery) mac_address = await _async_mac_address_from_discovery(self.hass, discovery)
data = { data = {
CONFIG_ENTRY_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_UDN: discovery.ssdp_udn,
CONFIG_ENTRY_ST: discovery.ssdp_st, CONFIG_ENTRY_ST: discovery.ssdp_st,
CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_ORIGINAL_UDN: discovery.ssdp_udn,
CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_MAC_ADDRESS: mac_address,
CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"],
CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations),
@ -265,9 +264,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
title = _friendly_name_from_discovery(discovery) title = _friendly_name_from_discovery(discovery)
mac_address = await _async_mac_address_from_discovery(self.hass, discovery) mac_address = await _async_mac_address_from_discovery(self.hass, discovery)
data = { data = {
CONFIG_ENTRY_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_UDN: discovery.ssdp_udn,
CONFIG_ENTRY_ST: discovery.ssdp_st, CONFIG_ENTRY_ST: discovery.ssdp_st,
CONFIG_ENTRY_ORIGINAL_UDN: discovery.upnp[ssdp.ATTR_UPNP_UDN], CONFIG_ENTRY_ORIGINAL_UDN: discovery.ssdp_udn,
CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations), CONFIG_ENTRY_LOCATION: get_preferred_location(discovery.ssdp_all_locations),
CONFIG_ENTRY_MAC_ADDRESS: mac_address, CONFIG_ENTRY_MAC_ADDRESS: mac_address,
CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"], CONFIG_ENTRY_HOST: discovery.ssdp_headers["_host"],

View File

@ -52,6 +52,7 @@ SERVICE_SET_VICARE_MODE_ATTR_MODE = "vicare_mode"
VICARE_MODE_DHW = "dhw" VICARE_MODE_DHW = "dhw"
VICARE_MODE_HEATING = "heating" VICARE_MODE_HEATING = "heating"
VICARE_MODE_HEATINGCOOLING = "heatingCooling"
VICARE_MODE_DHWANDHEATING = "dhwAndHeating" VICARE_MODE_DHWANDHEATING = "dhwAndHeating"
VICARE_MODE_DHWANDHEATINGCOOLING = "dhwAndHeatingCooling" VICARE_MODE_DHWANDHEATINGCOOLING = "dhwAndHeatingCooling"
VICARE_MODE_FORCEDREDUCED = "forcedReduced" VICARE_MODE_FORCEDREDUCED = "forcedReduced"
@ -71,6 +72,7 @@ VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = {
VICARE_MODE_DHW: HVACMode.OFF, VICARE_MODE_DHW: HVACMode.OFF,
VICARE_MODE_DHWANDHEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_DHWANDHEATINGCOOLING: HVACMode.AUTO,
VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, VICARE_MODE_DHWANDHEATING: HVACMode.AUTO,
VICARE_MODE_HEATINGCOOLING: HVACMode.AUTO,
VICARE_MODE_HEATING: HVACMode.AUTO, VICARE_MODE_HEATING: HVACMode.AUTO,
VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT,
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["weatherflow4py==0.1.12"] "requirements": ["weatherflow4py==0.1.17"]
} }

View File

@ -491,15 +491,19 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
VERSION = 4 VERSION = 4
async def _set_unique_id_or_update_path( async def _set_unique_id_and_update_ignored_flow(
self, unique_id: str, device_path: str self, unique_id: str, device_path: str
) -> None: ) -> None:
"""Set the flow's unique ID and update the device path if it isn't unique.""" """Set the flow's unique ID and update the device path in an ignored flow."""
current_entry = await self.async_set_unique_id(unique_id) current_entry = await self.async_set_unique_id(unique_id)
if not current_entry: if not current_entry:
return return
if current_entry.source != config_entries.SOURCE_IGNORE:
self._abort_if_unique_id_configured()
else:
# Only update the current entry if it is an ignored discovery
self._abort_if_unique_id_configured( self._abort_if_unique_id_configured(
updates={ updates={
CONF_DEVICE: { CONF_DEVICE: {
@ -575,7 +579,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
description = discovery_info.description description = discovery_info.description
dev_path = discovery_info.device dev_path = discovery_info.device
await self._set_unique_id_or_update_path( await self._set_unique_id_and_update_ignored_flow(
unique_id=f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}", unique_id=f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}",
device_path=dev_path, device_path=dev_path,
) )
@ -625,7 +629,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
node_name = local_name.removesuffix(".local") node_name = local_name.removesuffix(".local")
device_path = f"socket://{discovery_info.host}:{port}" device_path = f"socket://{discovery_info.host}:{port}"
await self._set_unique_id_or_update_path( await self._set_unique_id_and_update_ignored_flow(
unique_id=node_name, unique_id=node_name,
device_path=device_path, device_path=device_path,
) )
@ -650,7 +654,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
device_settings = discovery_data["port"] device_settings = discovery_data["port"]
device_path = device_settings[CONF_DEVICE_PATH] device_path = device_settings[CONF_DEVICE_PATH]
await self._set_unique_id_or_update_path( await self._set_unique_id_and_update_ignored_flow(
unique_id=f"{name}_{radio_type.name}_{device_path}", unique_id=f"{name}_{radio_type.name}_{device_path}",
device_path=device_path, device_path=device_path,
) )

View File

@ -16,7 +16,7 @@ from .helpers.deprecation import (
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 3 MINOR_VERSION: Final = 3
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, 11, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0)

View File

@ -255,6 +255,7 @@ FLOWS = {
"isy994", "isy994",
"izone", "izone",
"jellyfin", "jellyfin",
"juicenet",
"justnimbus", "justnimbus",
"jvc_projector", "jvc_projector",
"kaleidescape", "kaleidescape",

View File

@ -2911,6 +2911,12 @@
"config_flow": false, "config_flow": false,
"iot_class": "cloud_push" "iot_class": "cloud_push"
}, },
"juicenet": {
"name": "JuiceNet",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"justnimbus": { "justnimbus": {
"name": "JustNimbus", "name": "JustNimbus",
"integration_type": "hub", "integration_type": "hub",

View File

@ -1,6 +1,6 @@
# Automatically generated by gen_requirements_all.py, do not edit # Automatically generated by gen_requirements_all.py, do not edit
aiodhcpwatcher==0.8.0 aiodhcpwatcher==0.8.1
aiodiscover==1.6.1 aiodiscover==1.6.1
aiohttp-fast-url-dispatcher==0.3.0 aiohttp-fast-url-dispatcher==0.3.0
aiohttp-zlib-ng==0.3.1 aiohttp-zlib-ng==0.3.1
@ -30,8 +30,8 @@ habluetooth==2.4.2
hass-nabucasa==0.78.0 hass-nabucasa==0.78.0
hassil==1.6.1 hassil==1.6.1
home-assistant-bluetooth==1.12.0 home-assistant-bluetooth==1.12.0
home-assistant-frontend==20240306.0 home-assistant-frontend==20240307.0
home-assistant-intents==2024.2.28 home-assistant-intents==2024.3.12
httpx==0.27.0 httpx==0.27.0
ifaddr==0.2.0 ifaddr==0.2.0
janus==1.0.0 janus==1.0.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2024.3.0" version = "2024.3.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"
@ -35,6 +35,9 @@ dependencies = [
"bcrypt==4.1.2", "bcrypt==4.1.2",
"certifi>=2021.5.30", "certifi>=2021.5.30",
"ciso8601==2.3.1", "ciso8601==2.3.1",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==0.78.0",
# When bumping httpx, please check the version pins of # When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all # httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.27.0", "httpx==0.27.0",
@ -525,9 +528,6 @@ filterwarnings = [
"ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku",
# https://github.com/thecynic/pylutron - v0.2.10 # https://github.com/thecynic/pylutron - v0.2.10
"ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron",
# Fixed for Python 3.12
# https://github.com/lextudio/pysnmp/issues/10
"ignore:The asyncore module is deprecated and will be removed in Python 3.12:DeprecationWarning:pysnmp.carrier.asyncore.base",
# Wrong stacklevel # Wrong stacklevel
# https://bugs.launchpad.net/beautifulsoup/+bug/2034451 # https://bugs.launchpad.net/beautifulsoup/+bug/2034451
"ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder",

View File

@ -15,6 +15,7 @@ awesomeversion==24.2.0
bcrypt==4.1.2 bcrypt==4.1.2
certifi>=2021.5.30 certifi>=2021.5.30
ciso8601==2.3.1 ciso8601==2.3.1
hass-nabucasa==0.78.0
httpx==0.27.0 httpx==0.27.0
home-assistant-bluetooth==1.12.0 home-assistant-bluetooth==1.12.0
ifaddr==0.2.0 ifaddr==0.2.0

View File

@ -206,7 +206,7 @@ aioaseko==0.0.2
aioasuswrt==1.4.0 aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower # homeassistant.components.husqvarna_automower
aioautomower==2024.2.10 aioautomower==2024.3.0
# homeassistant.components.azure_devops # homeassistant.components.azure_devops
aioazuredevops==1.3.5 aioazuredevops==1.3.5
@ -221,7 +221,7 @@ aiobotocore==2.9.1
aiocomelit==0.9.0 aiocomelit==0.9.0
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodhcpwatcher==0.8.0 aiodhcpwatcher==0.8.1
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==1.6.1 aiodiscover==1.6.1
@ -315,7 +315,7 @@ aiomusiccast==0.14.8
aionanoleaf==0.2.1 aionanoleaf==0.2.1
# homeassistant.components.notion # homeassistant.components.notion
aionotion==2024.02.2 aionotion==2024.03.0
# homeassistant.components.oncue # homeassistant.components.oncue
aiooncue==0.3.5 aiooncue==0.3.5
@ -419,7 +419,7 @@ aioymaps==1.2.2
airly==1.1.0 airly==1.1.0
# homeassistant.components.airthings_ble # homeassistant.components.airthings_ble
airthings-ble==0.6.1 airthings-ble==0.7.1
# homeassistant.components.airthings # homeassistant.components.airthings
airthings-cloud==0.2.0 airthings-cloud==0.2.0
@ -514,7 +514,7 @@ aurorapy==0.2.7
# avion==0.10 # avion==0.10
# homeassistant.components.axis # homeassistant.components.axis
axis==50 axis==54
# homeassistant.components.azure_event_hub # homeassistant.components.azure_event_hub
azure-eventhub==5.11.1 azure-eventhub==5.11.1
@ -596,20 +596,20 @@ bluetooth-data-tools==1.19.0
bond-async==0.2.1 bond-async==0.2.1
# homeassistant.components.bosch_shc # homeassistant.components.bosch_shc
boschshcpy==0.2.75 boschshcpy==0.2.82
# homeassistant.components.amazon_polly # homeassistant.components.amazon_polly
# homeassistant.components.route53 # homeassistant.components.route53
boto3==1.33.13 boto3==1.33.13
# homeassistant.components.bring # homeassistant.components.bring
bring-api==0.5.5 bring-api==0.5.6
# homeassistant.components.broadlink # homeassistant.components.broadlink
broadlink==0.18.3 broadlink==0.18.3
# homeassistant.components.brother # homeassistant.components.brother
brother==4.0.0 brother==4.0.2
# homeassistant.components.brottsplatskartan # homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5 brottsplatskartan==1.0.5
@ -621,7 +621,7 @@ brunt==1.2.0
bt-proximity==0.2.1 bt-proximity==0.2.1
# homeassistant.components.bthome # homeassistant.components.bthome
bthome-ble==3.6.0 bthome-ble==3.8.0
# homeassistant.components.bt_home_hub_5 # homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1 bthomehub5-devicelist==0.1.1
@ -1074,10 +1074,10 @@ hole==0.8.0
holidays==0.44 holidays==0.44
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20240306.0 home-assistant-frontend==20240307.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2024.2.28 home-assistant-intents==2024.3.12
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.7.2 homeconnect==0.7.2
@ -1115,7 +1115,7 @@ ibmiotf==0.3.4
# homeassistant.components.google # homeassistant.components.google
# homeassistant.components.local_calendar # homeassistant.components.local_calendar
# homeassistant.components.local_todo # homeassistant.components.local_todo
ical==7.0.0 ical==7.0.1
# homeassistant.components.ping # homeassistant.components.ping
icmplib==3.0 icmplib==3.0
@ -1253,7 +1253,7 @@ logi-circle==0.2.3
london-tube-status==0.5 london-tube-status==0.5
# homeassistant.components.loqed # homeassistant.components.loqed
loqedAPI==2.1.8 loqedAPI==2.1.10
# homeassistant.components.luftdaten # homeassistant.components.luftdaten
luftdaten==0.7.4 luftdaten==0.7.4
@ -1409,7 +1409,7 @@ nsw-fuel-api-client==1.1.0
nuheat==1.0.1 nuheat==1.0.1
# homeassistant.components.numato # homeassistant.components.numato
numato-gpio==0.10.0 numato-gpio==0.12.0
# homeassistant.components.compensation # homeassistant.components.compensation
# homeassistant.components.iqvia # homeassistant.components.iqvia
@ -1474,7 +1474,7 @@ opensensemap-api==0.2.0
openwebifpy==4.2.4 openwebifpy==4.2.4
# homeassistant.components.luci # homeassistant.components.luci
openwrt-luci-rpc==1.1.16 openwrt-luci-rpc==1.1.17
# homeassistant.components.ubus # homeassistant.components.ubus
openwrt-ubus-rpc==0.0.2 openwrt-ubus-rpc==0.0.2
@ -1794,7 +1794,7 @@ pyedimax==0.2.1
pyefergy==22.1.1 pyefergy==22.1.1
# homeassistant.components.enphase_envoy # homeassistant.components.enphase_envoy
pyenphase==1.19.1 pyenphase==1.19.2
# homeassistant.components.envisalink # homeassistant.components.envisalink
pyenvisalink==4.6 pyenvisalink==4.6
@ -1878,7 +1878,7 @@ pyintesishome==1.8.0
pyipma==3.0.7 pyipma==3.0.7
# homeassistant.components.ipp # homeassistant.components.ipp
pyipp==0.14.5 pyipp==0.15.0
# homeassistant.components.iqvia # homeassistant.components.iqvia
pyiqvia==2022.04.0 pyiqvia==2022.04.0
@ -1971,7 +1971,7 @@ pymitv==1.4.3
pymochad==0.2.0 pymochad==0.2.0
# homeassistant.components.modbus # homeassistant.components.modbus
pymodbus==3.6.4 pymodbus==3.6.5
# homeassistant.components.monoprice # homeassistant.components.monoprice
pymonoprice==0.4 pymonoprice==0.4
@ -2155,7 +2155,7 @@ pysmartthings==0.7.8
pysml==0.0.12 pysml==0.0.12
# homeassistant.components.snmp # homeassistant.components.snmp
pysnmp-lextudio==6.0.2 pysnmp-lextudio==6.0.9
# homeassistant.components.snooz # homeassistant.components.snooz
pysnooz==0.8.6 pysnooz==0.8.6
@ -2182,7 +2182,7 @@ pyswitchbee==1.8.0
pytautulli==23.1.1 pytautulli==23.1.1
# homeassistant.components.tedee # homeassistant.components.tedee
pytedee-async==0.2.15 pytedee-async==0.2.16
# homeassistant.components.tfiac # homeassistant.components.tfiac
pytfiac==0.4 pytfiac==0.4
@ -2244,6 +2244,9 @@ python-izone==1.2.9
# homeassistant.components.joaoapps_join # homeassistant.components.joaoapps_join
python-join-api==0.0.9 python-join-api==0.0.9
# homeassistant.components.juicenet
python-juicenet==1.1.0
# homeassistant.components.tplink # homeassistant.components.tplink
python-kasa[speedups]==0.6.2.1 python-kasa[speedups]==0.6.2.1
@ -2454,7 +2457,7 @@ rjpl==0.3.6
rocketchat-API==0.6.1 rocketchat-API==0.6.1
# homeassistant.components.roku # homeassistant.components.roku
rokuecp==0.19.1 rokuecp==0.19.2
# homeassistant.components.romy # homeassistant.components.romy
romy==0.0.7 romy==0.0.7
@ -2466,7 +2469,7 @@ roombapy==1.6.13
roonapi==0.1.6 roonapi==0.1.6
# homeassistant.components.rova # homeassistant.components.rova
rova==0.4.0 rova==0.4.1
# homeassistant.components.rpi_power # homeassistant.components.rpi_power
rpi-bad-power==0.1.0 rpi-bad-power==0.1.0
@ -2836,7 +2839,7 @@ watchdog==2.3.1
waterfurnace==1.1.0 waterfurnace==1.1.0
# homeassistant.components.weatherflow_cloud # homeassistant.components.weatherflow_cloud
weatherflow4py==0.1.12 weatherflow4py==0.1.17
# homeassistant.components.webmin # homeassistant.components.webmin
webmin-xmlrpc==0.0.1 webmin-xmlrpc==0.0.1

View File

@ -185,7 +185,7 @@ aioaseko==0.0.2
aioasuswrt==1.4.0 aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower # homeassistant.components.husqvarna_automower
aioautomower==2024.2.10 aioautomower==2024.3.0
# homeassistant.components.azure_devops # homeassistant.components.azure_devops
aioazuredevops==1.3.5 aioazuredevops==1.3.5
@ -200,7 +200,7 @@ aiobotocore==2.9.1
aiocomelit==0.9.0 aiocomelit==0.9.0
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodhcpwatcher==0.8.0 aiodhcpwatcher==0.8.1
# homeassistant.components.dhcp # homeassistant.components.dhcp
aiodiscover==1.6.1 aiodiscover==1.6.1
@ -288,7 +288,7 @@ aiomusiccast==0.14.8
aionanoleaf==0.2.1 aionanoleaf==0.2.1
# homeassistant.components.notion # homeassistant.components.notion
aionotion==2024.02.2 aionotion==2024.03.0
# homeassistant.components.oncue # homeassistant.components.oncue
aiooncue==0.3.5 aiooncue==0.3.5
@ -392,7 +392,7 @@ aioymaps==1.2.2
airly==1.1.0 airly==1.1.0
# homeassistant.components.airthings_ble # homeassistant.components.airthings_ble
airthings-ble==0.6.1 airthings-ble==0.7.1
# homeassistant.components.airthings # homeassistant.components.airthings
airthings-cloud==0.2.0 airthings-cloud==0.2.0
@ -454,7 +454,7 @@ auroranoaa==0.0.3
aurorapy==0.2.7 aurorapy==0.2.7
# homeassistant.components.axis # homeassistant.components.axis
axis==50 axis==54
# homeassistant.components.azure_event_hub # homeassistant.components.azure_event_hub
azure-eventhub==5.11.1 azure-eventhub==5.11.1
@ -511,16 +511,16 @@ bluetooth-data-tools==1.19.0
bond-async==0.2.1 bond-async==0.2.1
# homeassistant.components.bosch_shc # homeassistant.components.bosch_shc
boschshcpy==0.2.75 boschshcpy==0.2.82
# homeassistant.components.bring # homeassistant.components.bring
bring-api==0.5.5 bring-api==0.5.6
# homeassistant.components.broadlink # homeassistant.components.broadlink
broadlink==0.18.3 broadlink==0.18.3
# homeassistant.components.brother # homeassistant.components.brother
brother==4.0.0 brother==4.0.2
# homeassistant.components.brottsplatskartan # homeassistant.components.brottsplatskartan
brottsplatskartan==1.0.5 brottsplatskartan==1.0.5
@ -529,7 +529,7 @@ brottsplatskartan==1.0.5
brunt==1.2.0 brunt==1.2.0
# homeassistant.components.bthome # homeassistant.components.bthome
bthome-ble==3.6.0 bthome-ble==3.8.0
# homeassistant.components.buienradar # homeassistant.components.buienradar
buienradar==1.0.5 buienradar==1.0.5
@ -873,10 +873,10 @@ hole==0.8.0
holidays==0.44 holidays==0.44
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20240306.0 home-assistant-frontend==20240307.0
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2024.2.28 home-assistant-intents==2024.3.12
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.7.2 homeconnect==0.7.2
@ -905,7 +905,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.google # homeassistant.components.google
# homeassistant.components.local_calendar # homeassistant.components.local_calendar
# homeassistant.components.local_todo # homeassistant.components.local_todo
ical==7.0.0 ical==7.0.1
# homeassistant.components.ping # homeassistant.components.ping
icmplib==3.0 icmplib==3.0
@ -1001,7 +1001,7 @@ logi-circle==0.2.3
london-tube-status==0.5 london-tube-status==0.5
# homeassistant.components.loqed # homeassistant.components.loqed
loqedAPI==2.1.8 loqedAPI==2.1.10
# homeassistant.components.luftdaten # homeassistant.components.luftdaten
luftdaten==0.7.4 luftdaten==0.7.4
@ -1127,7 +1127,7 @@ nsw-fuel-api-client==1.1.0
nuheat==1.0.1 nuheat==1.0.1
# homeassistant.components.numato # homeassistant.components.numato
numato-gpio==0.10.0 numato-gpio==0.12.0
# homeassistant.components.compensation # homeassistant.components.compensation
# homeassistant.components.iqvia # homeassistant.components.iqvia
@ -1390,7 +1390,7 @@ pyeconet==0.1.22
pyefergy==22.1.1 pyefergy==22.1.1
# homeassistant.components.enphase_envoy # homeassistant.components.enphase_envoy
pyenphase==1.19.1 pyenphase==1.19.2
# homeassistant.components.everlights # homeassistant.components.everlights
pyeverlights==0.1.0 pyeverlights==0.1.0
@ -1453,7 +1453,7 @@ pyinsteon==1.5.3
pyipma==3.0.7 pyipma==3.0.7
# homeassistant.components.ipp # homeassistant.components.ipp
pyipp==0.14.5 pyipp==0.15.0
# homeassistant.components.iqvia # homeassistant.components.iqvia
pyiqvia==2022.04.0 pyiqvia==2022.04.0
@ -1525,7 +1525,7 @@ pymeteoclimatic==0.1.0
pymochad==0.2.0 pymochad==0.2.0
# homeassistant.components.modbus # homeassistant.components.modbus
pymodbus==3.6.4 pymodbus==3.6.5
# homeassistant.components.monoprice # homeassistant.components.monoprice
pymonoprice==0.4 pymonoprice==0.4
@ -1673,7 +1673,7 @@ pysmartthings==0.7.8
pysml==0.0.12 pysml==0.0.12
# homeassistant.components.snmp # homeassistant.components.snmp
pysnmp-lextudio==6.0.2 pysnmp-lextudio==6.0.9
# homeassistant.components.snooz # homeassistant.components.snooz
pysnooz==0.8.6 pysnooz==0.8.6
@ -1697,7 +1697,7 @@ pyswitchbee==1.8.0
pytautulli==23.1.1 pytautulli==23.1.1
# homeassistant.components.tedee # homeassistant.components.tedee
pytedee-async==0.2.15 pytedee-async==0.2.16
# homeassistant.components.motionmount # homeassistant.components.motionmount
python-MotionMount==0.3.1 python-MotionMount==0.3.1
@ -1723,6 +1723,9 @@ python-homewizard-energy==4.3.1
# homeassistant.components.izone # homeassistant.components.izone
python-izone==1.2.9 python-izone==1.2.9
# homeassistant.components.juicenet
python-juicenet==1.1.0
# homeassistant.components.tplink # homeassistant.components.tplink
python-kasa[speedups]==0.6.2.1 python-kasa[speedups]==0.6.2.1
@ -1882,7 +1885,7 @@ rflink==0.0.66
ring-doorbell[listen]==0.8.7 ring-doorbell[listen]==0.8.7
# homeassistant.components.roku # homeassistant.components.roku
rokuecp==0.19.1 rokuecp==0.19.2
# homeassistant.components.romy # homeassistant.components.romy
romy==0.0.7 romy==0.0.7
@ -2174,7 +2177,7 @@ wallbox==0.6.0
watchdog==2.3.1 watchdog==2.3.1
# homeassistant.components.weatherflow_cloud # homeassistant.components.weatherflow_cloud
weatherflow4py==0.1.12 weatherflow4py==0.1.17
# homeassistant.components.webmin # homeassistant.components.webmin
webmin-xmlrpc==0.0.1 webmin-xmlrpc==0.0.1

View File

@ -33,6 +33,7 @@ IGNORE_VIOLATIONS = {
"blink", "blink",
"ezviz", "ezviz",
"hdmi_cec", "hdmi_cec",
"juicenet",
"lupusec", "lupusec",
"rainbird", "rainbird",
"slide", "slide",

View File

@ -3,7 +3,11 @@ from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice from airthings_ble import (
AirthingsBluetoothDeviceData,
AirthingsDevice,
AirthingsDeviceType,
)
from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.airthings_ble.const import DOMAIN
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
@ -161,8 +165,7 @@ WAVE_DEVICE_INFO = AirthingsDevice(
manufacturer="Airthings AS", manufacturer="Airthings AS",
hw_version="REV A", hw_version="REV A",
sw_version="G-BLE-1.5.3-master+0", sw_version="G-BLE-1.5.3-master+0",
model="Wave Plus", model=AirthingsDeviceType.WAVE_PLUS,
model_raw="2930",
name="Airthings Wave+", name="Airthings Wave+",
identifier="123456", identifier="123456",
sensors={ sensors={

View File

@ -1,7 +1,7 @@
"""Test the Airthings BLE config flow.""" """Test the Airthings BLE config flow."""
from unittest.mock import patch from unittest.mock import patch
from airthings_ble import AirthingsDevice from airthings_ble import AirthingsDevice, AirthingsDeviceType
from bleak import BleakError from bleak import BleakError
from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.airthings_ble.const import DOMAIN
@ -28,8 +28,7 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
with patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble( with patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble(
AirthingsDevice( AirthingsDevice(
manufacturer="Airthings AS", manufacturer="Airthings AS",
model="Wave Plus", model=AirthingsDeviceType.WAVE_PLUS,
model_raw="2930",
name="Airthings Wave Plus", name="Airthings Wave Plus",
identifier="123456", identifier="123456",
) )
@ -111,8 +110,7 @@ async def test_user_setup(hass: HomeAssistant) -> None:
), patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble( ), patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble(
AirthingsDevice( AirthingsDevice(
manufacturer="Airthings AS", manufacturer="Airthings AS",
model="Wave Plus", model=AirthingsDeviceType.WAVE_PLUS,
model_raw="2930",
name="Airthings Wave Plus", name="Airthings Wave Plus",
identifier="123456", identifier="123456",
) )

View File

@ -7,6 +7,7 @@ from homeassistant.components import camera
from homeassistant.components.camera.const import StreamType from homeassistant.components.camera.const import StreamType
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .common import WEBRTC_ANSWER from .common import WEBRTC_ANSWER
@ -69,3 +70,37 @@ async def mock_camera_web_rtc_fixture(hass):
return_value=WEBRTC_ANSWER, return_value=WEBRTC_ANSWER,
): ):
yield yield
@pytest.fixture(name="mock_camera_with_device")
async def mock_camera_with_device_fixture():
"""Initialize a demo camera platform with a device."""
dev_info = DeviceInfo(
identifiers={("camera", "test_unique_id")},
name="Test Camera Device",
)
class UniqueIdMock(PropertyMock):
def __get__(self, obj, obj_type=None):
return obj.name
with patch(
"homeassistant.components.camera.Camera.has_entity_name",
new_callable=PropertyMock(return_value=True),
), patch(
"homeassistant.components.camera.Camera.unique_id", new=UniqueIdMock()
), patch(
"homeassistant.components.camera.Camera.device_info",
new_callable=PropertyMock(return_value=dev_info),
):
yield
@pytest.fixture(name="mock_camera_with_no_name")
async def mock_camera_with_no_name_fixture(mock_camera_with_device):
"""Initialize a demo camera platform with a device and no name."""
with patch(
"homeassistant.components.camera.Camera._attr_name",
new_callable=PropertyMock(return_value=None),
):
yield

View File

@ -16,6 +16,26 @@ async def setup_media_source(hass):
assert await async_setup_component(hass, "media_source", {}) assert await async_setup_component(hass, "media_source", {})
async def test_device_with_device(
hass: HomeAssistant, mock_camera_with_device, mock_camera
) -> None:
"""Test browsing when camera has a device and a name."""
item = await media_source.async_browse_media(hass, "media-source://camera")
assert item.not_shown == 2
assert len(item.children) == 1
assert item.children[0].title == "Test Camera Device Demo camera without stream"
async def test_device_with_no_name(
hass: HomeAssistant, mock_camera_with_no_name, mock_camera
) -> None:
"""Test browsing when camera has device and name == None."""
item = await media_source.async_browse_media(hass, "media-source://camera")
assert item.not_shown == 2
assert len(item.children) == 1
assert item.children[0].title == "Test Camera Device Demo camera without stream"
async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None: async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None:
"""Test browsing HLS camera media source.""" """Test browsing HLS camera media source."""
item = await media_source.async_browse_media(hass, "media-source://camera") item = await media_source.async_browse_media(hass, "media-source://camera")
@ -41,6 +61,7 @@ async def test_browsing_mjpeg(hass: HomeAssistant, mock_camera) -> None:
assert len(item.children) == 1 assert len(item.children) == 1
assert item.not_shown == 2 assert item.not_shown == 2
assert item.children[0].media_content_type == "image/jpg" assert item.children[0].media_content_type == "image/jpg"
assert item.children[0].title == "Demo camera without stream"
async def test_browsing_web_rtc(hass: HomeAssistant, mock_camera_web_rtc) -> None: async def test_browsing_web_rtc(hass: HomeAssistant, mock_camera_web_rtc) -> None:

View File

@ -1,4 +1,6 @@
"""Test sensor of GIOS integration.""" """Test sensor of GIOS integration."""
from copy import deepcopy
from datetime import timedelta from datetime import timedelta
import json import json
from unittest.mock import patch from unittest.mock import patch
@ -288,10 +290,12 @@ async def test_availability(hass: HomeAssistant) -> None:
assert state assert state
assert state.state == STATE_UNAVAILABLE assert state.state == STATE_UNAVAILABLE
incomplete_sensors = deepcopy(sensors)
incomplete_sensors["pm2.5"] = {}
future = utcnow() + timedelta(minutes=120) future = utcnow() + timedelta(minutes=120)
with patch( with patch(
"homeassistant.components.gios.Gios._get_all_sensors", "homeassistant.components.gios.Gios._get_all_sensors",
return_value=sensors, return_value=incomplete_sensors,
), patch( ), patch(
"homeassistant.components.gios.Gios._get_indexes", "homeassistant.components.gios.Gios._get_indexes",
return_value={}, return_value={},
@ -299,9 +303,10 @@ async def test_availability(hass: HomeAssistant) -> None:
async_fire_time_changed(hass, future) async_fire_time_changed(hass, future)
await hass.async_block_till_done() await hass.async_block_till_done()
# There is no PM2.5 data so the state should be unavailable
state = hass.states.get("sensor.home_pm2_5") state = hass.states.get("sensor.home_pm2_5")
assert state assert state
assert state.state == "4" assert state.state == STATE_UNAVAILABLE
# Indexes are empty so the state should be unavailable # Indexes are empty so the state should be unavailable
state = hass.states.get("sensor.home_air_quality_index") state = hass.states.get("sensor.home_air_quality_index")

View File

@ -1,5 +1,6 @@
"""Tests for diagnostics platform of google calendar.""" """Tests for diagnostics platform of google calendar."""
from collections.abc import Callable from collections.abc import Callable
import time
from typing import Any from typing import Any
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
@ -15,6 +16,7 @@ from .conftest import TEST_EVENT, ComponentSetup
from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.common import CLIENT_ID, MockConfigEntry, MockUser
from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator from tests.typing import ClientSessionGenerator
@ -69,8 +71,21 @@ async def test_diagnostics(
aiohttp_client: ClientSessionGenerator, aiohttp_client: ClientSessionGenerator,
socket_enabled: None, socket_enabled: None,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
aioclient_mock: AiohttpClientMocker,
) -> None: ) -> None:
"""Test diagnostics for the calendar.""" """Test diagnostics for the calendar."""
expires_in = 86400
expires_at = time.time() + expires_in
aioclient_mock.post(
"https://oauth2.googleapis.com/token",
json={
"refresh_token": "some-refresh-token",
"access_token": "some-updated-token",
"expires_at": expires_at,
"expires_in": expires_in,
},
)
mock_events_list_items( mock_events_list_items(
[ [
{ {

View File

@ -1,5 +1,6 @@
"""Tests for the Google Assistant traits.""" """Tests for the Google Assistant traits."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
@ -3925,16 +3926,15 @@ async def test_air_quality_description_for_aqi(hass: HomeAssistant) -> None:
BASIC_CONFIG, BASIC_CONFIG,
) )
assert trt._air_quality_description_for_aqi("0") == "healthy" assert trt._air_quality_description_for_aqi(0) == "healthy"
assert trt._air_quality_description_for_aqi("75") == "moderate" assert trt._air_quality_description_for_aqi(75) == "moderate"
assert ( assert (
trt._air_quality_description_for_aqi("125") == "unhealthy for sensitive groups" trt._air_quality_description_for_aqi(125.0) == "unhealthy for sensitive groups"
) )
assert trt._air_quality_description_for_aqi("175") == "unhealthy" assert trt._air_quality_description_for_aqi(175) == "unhealthy"
assert trt._air_quality_description_for_aqi("250") == "very unhealthy" assert trt._air_quality_description_for_aqi(250) == "very unhealthy"
assert trt._air_quality_description_for_aqi("350") == "hazardous" assert trt._air_quality_description_for_aqi(350) == "hazardous"
assert trt._air_quality_description_for_aqi("-1") == "unknown" assert trt._air_quality_description_for_aqi(-1) == "unknown"
assert trt._air_quality_description_for_aqi("non-numeric") == "unknown"
async def test_null_device_class(hass: HomeAssistant) -> None: async def test_null_device_class(hass: HomeAssistant) -> None:
@ -3955,7 +3955,19 @@ async def test_null_device_class(hass: HomeAssistant) -> None:
assert trt.query_attributes() == {} assert trt.query_attributes() == {}
async def test_sensorstate(hass: HomeAssistant) -> None: @pytest.mark.parametrize(
("value", "published", "aqi"),
[
(100.0, 100.0, "moderate"),
(10.0, 10.0, "healthy"),
(0, 0.0, "healthy"),
("", None, "unknown"),
("unknown", None, "unknown"),
],
)
async def test_sensorstate(
hass: HomeAssistant, value: Any, published: Any, aqi: Any
) -> None:
"""Test SensorState trait support for sensor domain.""" """Test SensorState trait support for sensor domain."""
sensor_types = { sensor_types = {
sensor.SensorDeviceClass.AQI: ("AirQuality", "AQI"), sensor.SensorDeviceClass.AQI: ("AirQuality", "AQI"),
@ -3977,7 +3989,7 @@ async def test_sensorstate(hass: HomeAssistant) -> None:
hass, hass,
State( State(
"sensor.test", "sensor.test",
100.0, value,
{ {
"device_class": sensor_type, "device_class": sensor_type,
}, },
@ -4023,16 +4035,14 @@ async def test_sensorstate(hass: HomeAssistant) -> None:
"currentSensorStateData": [ "currentSensorStateData": [
{ {
"name": name, "name": name,
"currentSensorState": trt._air_quality_description_for_aqi( "currentSensorState": aqi,
trt.state.state "rawValue": published,
),
"rawValue": trt.state.state,
}, },
] ]
} }
else: else:
assert trt.query_attributes() == { assert trt.query_attributes() == {
"currentSensorStateData": [{"name": name, "rawValue": trt.state.state}] "currentSensorStateData": [{"name": name, "rawValue": published}]
} }
assert helpers.get_google_type(sensor.DOMAIN, None) is not None assert helpers.get_google_type(sensor.DOMAIN, None) is not None

View File

@ -1,6 +1,8 @@
"""Test issues from supervisor issues.""" """Test issues from supervisor issues."""
from __future__ import annotations from __future__ import annotations
import asyncio
from http import HTTPStatus
import os import os
from typing import Any from typing import Any
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
@ -13,7 +15,7 @@ from homeassistant.setup import async_setup_component
from .test_init import MOCK_ENVIRON from .test_init import MOCK_ENVIRON
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse
from tests.typing import WebSocketGenerator from tests.typing import WebSocketGenerator
@ -40,6 +42,7 @@ def mock_resolution_info(
unsupported: list[str] | None = None, unsupported: list[str] | None = None,
unhealthy: list[str] | None = None, unhealthy: list[str] | None = None,
issues: list[dict[str, str]] | None = None, issues: list[dict[str, str]] | None = None,
suggestion_result: str = "ok",
): ):
"""Mock resolution/info endpoint with unsupported/unhealthy reasons and/or issues.""" """Mock resolution/info endpoint with unsupported/unhealthy reasons and/or issues."""
aioclient_mock.get( aioclient_mock.get(
@ -76,7 +79,7 @@ def mock_resolution_info(
for suggestion in suggestions: for suggestion in suggestions:
aioclient_mock.post( aioclient_mock.post(
f"http://127.0.0.1/resolution/suggestion/{suggestion['uuid']}", f"http://127.0.0.1/resolution/suggestion/{suggestion['uuid']}",
json={"result": "ok"}, json={"result": suggestion_result},
) )
@ -528,6 +531,80 @@ async def test_supervisor_issues(
) )
async def test_supervisor_issues_initial_failure(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test issues manager retries after initial update failure."""
responses = [
AiohttpClientMockResponse(
method="get",
url="http://127.0.0.1/resolution/info",
status=HTTPStatus.BAD_REQUEST,
json={
"result": "error",
"message": "System is not ready with state: setup",
},
),
AiohttpClientMockResponse(
method="get",
url="http://127.0.0.1/resolution/info",
status=HTTPStatus.OK,
json={
"result": "ok",
"data": {
"unsupported": [],
"unhealthy": [],
"suggestions": [],
"issues": [
{
"uuid": "1234",
"type": "reboot_required",
"context": "system",
"reference": None,
},
],
"checks": [
{"enabled": True, "slug": "supervisor_trust"},
{"enabled": True, "slug": "free_space"},
],
},
},
),
]
async def mock_responses(*args):
nonlocal responses
return responses.pop(0)
aioclient_mock.get(
"http://127.0.0.1/resolution/info",
side_effect=mock_responses,
)
aioclient_mock.get(
"http://127.0.0.1/resolution/issue/1234/suggestions",
json={"result": "ok", "data": {"suggestions": []}},
)
with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1):
result = await async_setup_component(hass, "hassio", {})
assert result
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 0
await asyncio.sleep(0.1)
await client.send_json({"id": 2, "type": "repairs/list_issues"})
msg = await client.receive_json()
assert msg["success"]
assert len(msg["result"]["issues"]) == 1
async def test_supervisor_issues_add_remove( async def test_supervisor_issues_add_remove(
hass: HomeAssistant, hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,

View File

@ -404,6 +404,78 @@ async def test_supervisor_issue_repair_flow_skip_confirmation(
) )
async def test_mount_failed_repair_flow_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
all_setup_requests,
) -> None:
"""Test repair flow fails when repair fails to apply."""
mock_resolution_info(
aioclient_mock,
issues=[
{
"uuid": "1234",
"type": "mount_failed",
"context": "mount",
"reference": "backup_share",
"suggestions": [
{
"uuid": "1235",
"type": "execute_reload",
"context": "mount",
"reference": "backup_share",
},
{
"uuid": "1236",
"type": "execute_remove",
"context": "mount",
"reference": "backup_share",
},
],
},
],
suggestion_result=False,
)
assert await async_setup_component(hass, "hassio", {})
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert repair_issue
client = await hass_client()
resp = await client.post(
"/api/repairs/issues/fix",
json={"handler": "hassio", "issue_id": repair_issue.issue_id},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
resp = await client.post(
f"/api/repairs/issues/fix/{flow_id}",
json={"next_step_id": "mount_execute_reload"},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data == {
"type": "abort",
"flow_id": flow_id,
"handler": "hassio",
"reason": "apply_suggestion_fail",
"result": None,
"description_placeholders": None,
}
assert issue_registry.async_get_issue(domain="hassio", issue_id="1234")
async def test_mount_failed_repair_flow( async def test_mount_failed_repair_flow(
hass: HomeAssistant, hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,

View File

@ -171,6 +171,7 @@ async def test_websocket_supervisor_api_error(
aioclient_mock.get( aioclient_mock.get(
"http://127.0.0.1/ping", "http://127.0.0.1/ping",
json={"result": "error", "message": "example error"}, json={"result": "error", "message": "example error"},
status=400,
) )
await websocket_client.send_json( await websocket_client.send_json(
@ -183,9 +184,39 @@ async def test_websocket_supervisor_api_error(
) )
msg = await websocket_client.receive_json() msg = await websocket_client.receive_json()
assert msg["error"]["code"] == "unknown_error"
assert msg["error"]["message"] == "example error" assert msg["error"]["message"] == "example error"
async def test_websocket_supervisor_api_error_without_msg(
hassio_env,
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test Supervisor websocket api error."""
assert await async_setup_component(hass, "hassio", {})
websocket_client = await hass_ws_client(hass)
aioclient_mock.get(
"http://127.0.0.1/ping",
json={},
status=400,
)
await websocket_client.send_json(
{
WS_ID: 1,
WS_TYPE: WS_TYPE_API,
ATTR_ENDPOINT: "/ping",
ATTR_METHOD: "get",
}
)
msg = await websocket_client.receive_json()
assert msg["error"]["code"] == "unknown_error"
assert msg["error"]["message"] == ""
async def test_websocket_non_admin_user( async def test_websocket_non_admin_user(
hassio_env, hassio_env,
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -62,6 +62,18 @@
"connected": true, "connected": true,
"statusTimestamp": 1697669932683 "statusTimestamp": 1697669932683
}, },
"workAreas": [
{
"workAreaId": 123456,
"name": "Front lawn",
"cuttingHeight": 50
},
{
"workAreaId": 0,
"name": "",
"cuttingHeight": 50
}
],
"positions": [ "positions": [
{ {
"latitude": 35.5402913, "latitude": 35.5402913,
@ -120,10 +132,6 @@
"longitude": -82.5520054 "longitude": -82.5520054
} }
], ],
"cuttingHeight": 4,
"headlight": {
"mode": "EVENING_ONLY"
},
"statistics": { "statistics": {
"cuttingBladeUsageTime": 123, "cuttingBladeUsageTime": 123,
"numberOfChargingCycles": 1380, "numberOfChargingCycles": 1380,
@ -133,6 +141,20 @@
"totalDriveDistance": 1780272, "totalDriveDistance": 1780272,
"totalRunningTime": 4564800, "totalRunningTime": 4564800,
"totalSearchingTime": 370800 "totalSearchingTime": 370800
},
"stayOutZones": {
"dirty": false,
"zones": [
{
"id": "81C6EEA2-D139-4FEA-B134-F22A6B3EA403",
"name": "Springflowers",
"enabled": true
}
]
},
"cuttingHeight": 4,
"headlight": {
"mode": "EVENING_ONLY"
} }
} }
} }

View File

@ -0,0 +1,100 @@
# serializer version: 1
# name: test_diagnostics
dict({
'data': dict({
'info': dict({
'command_set': 'ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF',
'location': None,
'manufacturer': 'TEST',
'model': 'HA-1000 Series',
'more_info': 'http://192.168.1.31:80/PRESENTATION/BONJOUR',
'name': 'Test HA-1000 Series',
'printer_info': 'Test HA-1000 Series',
'printer_name': 'Test Printer',
'printer_uri_supported': list([
'ipps://192.168.1.31:631/ipp/print',
'ipp://192.168.1.31:631/ipp/print',
]),
'serial': '555534593035345555',
'uptime': 30,
'uuid': 'cfe92100-67c4-11d4-a45f-f8d027761251',
'version': '20.23.06HA',
}),
'markers': list([
dict({
'color': '#000000',
'high_level': 100,
'level': 58,
'low_level': 10,
'marker_id': 0,
'marker_type': 'ink-cartridge',
'name': 'Black ink',
}),
dict({
'color': '#00FFFF',
'high_level': 100,
'level': 91,
'low_level': 10,
'marker_id': 2,
'marker_type': 'ink-cartridge',
'name': 'Cyan ink',
}),
dict({
'color': '#FF00FF',
'high_level': 100,
'level': 73,
'low_level': 10,
'marker_id': 4,
'marker_type': 'ink-cartridge',
'name': 'Magenta ink',
}),
dict({
'color': '#000000',
'high_level': 100,
'level': 98,
'low_level': 10,
'marker_id': 1,
'marker_type': 'ink-cartridge',
'name': 'Photo black ink',
}),
dict({
'color': '#FFFF00',
'high_level': 100,
'level': 95,
'low_level': 10,
'marker_id': 3,
'marker_type': 'ink-cartridge',
'name': 'Yellow ink',
}),
]),
'state': dict({
'message': None,
'printer_state': 'idle',
'reasons': None,
}),
'uris': list([
dict({
'authentication': None,
'security': 'tls',
'uri': 'ipps://192.168.1.31:631/ipp/print',
}),
dict({
'authentication': None,
'security': None,
'uri': 'ipp://192.168.1.31:631/ipp/print',
}),
]),
}),
'entry': dict({
'data': dict({
'base_path': '/ipp/print',
'host': '192.168.1.31',
'port': 631,
'ssl': False,
'uuid': 'cfe92100-67c4-11d4-a45f-f8d027761251',
'verify_ssl': True,
}),
'unique_id': 'cfe92100-67c4-11d4-a45f-f8d027761251',
}),
})
# ---

View File

@ -0,0 +1,22 @@
"""Tests for the diagnostics data provided by the Internet Printing Protocol (IPP) integration."""
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics for config entry."""
assert (
await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
== snapshot
)

View File

@ -0,0 +1,124 @@
"""Test the JuiceNet config flow."""
from unittest.mock import MagicMock, patch
import aiohttp
from pyjuicenet import TokenError
from homeassistant import config_entries
from homeassistant.components.juicenet.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
def _mock_juicenet_return_value(get_devices=None):
juicenet_mock = MagicMock()
type(juicenet_mock).get_devices = MagicMock(return_value=get_devices)
return juicenet_mock
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.juicenet.config_flow.Api.get_devices",
return_value=MagicMock(),
), patch(
"homeassistant.components.juicenet.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.juicenet.async_setup_entry", return_value=True
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"}
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "JuiceNet"
assert result2["data"] == {CONF_ACCESS_TOKEN: "access_token"}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.juicenet.config_flow.Api.get_devices",
side_effect=TokenError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"}
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.juicenet.config_flow.Api.get_devices",
side_effect=aiohttp.ClientError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"}
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_catch_unknown_errors(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.juicenet.config_flow.Api.get_devices",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"}
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}
async def test_import(hass: HomeAssistant) -> None:
"""Test that import works as expected."""
with patch(
"homeassistant.components.juicenet.config_flow.Api.get_devices",
return_value=MagicMock(),
), patch(
"homeassistant.components.juicenet.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.juicenet.async_setup_entry", return_value=True
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_ACCESS_TOKEN: "access_token"},
)
await hass.async_block_till_done()
assert result["type"] == "create_entry"
assert result["title"] == "JuiceNet"
assert result["data"] == {CONF_ACCESS_TOKEN: "access_token"}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1

Some files were not shown because too many files have changed in this diff Show More