forked from home-assistant/core
Compare commits
155 Commits
2024.3.0b3
...
2024.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25909f2ebd | ||
|
|
2d2249386e | ||
|
|
32b4814f2a | ||
|
|
d1644f3713 | ||
|
|
7b431a91b3 | ||
|
|
93289c9f09 | ||
|
|
f10d924e8b | ||
|
|
91bb321d8f | ||
|
|
19ef92735c | ||
|
|
6c274abc50 | ||
|
|
1e57f52ba2 | ||
|
|
8056886c66 | ||
|
|
14c4cdc089 | ||
|
|
4132a3d2ea | ||
|
|
d67cd2af0c | ||
|
|
33678ff5a4 | ||
|
|
6859bae0b1 | ||
|
|
368586c9d1 | ||
|
|
eb8a8424a5 | ||
|
|
686487e59c | ||
|
|
fa9f5bd647 | ||
|
|
2e2d303291 | ||
|
|
0a64ae2f7a | ||
|
|
a7908d8250 | ||
|
|
4a620e015f | ||
|
|
d5864a40a8 | ||
|
|
05b900321b | ||
|
|
5163b5f888 | ||
|
|
8bae8fdd75 | ||
|
|
de966b0eb1 | ||
|
|
099c228169 | ||
|
|
a5994d1d5f | ||
|
|
26b26a3b1f | ||
|
|
273d01c0f4 | ||
|
|
a167b0acaf | ||
|
|
8b00229868 | ||
|
|
10fc40e415 | ||
|
|
f7972ce9b2 | ||
|
|
05c0416644 | ||
|
|
63e3da1aca | ||
|
|
6ca837b4e1 | ||
|
|
45ef5a3edf | ||
|
|
297c7c11fc | ||
|
|
cda9bf7051 | ||
|
|
eb04365590 | ||
|
|
b88cdd78bc | ||
|
|
525b20ca8e | ||
|
|
5769ba023c | ||
|
|
f7da6b5e81 | ||
|
|
6fdfc554a6 | ||
|
|
962e5ec92a | ||
|
|
095d0d0779 | ||
|
|
d010df7116 | ||
|
|
a63bf74886 | ||
|
|
cac22154a8 | ||
|
|
0b2322c466 | ||
|
|
66cd6c0d23 | ||
|
|
a2e9ecfcde | ||
|
|
8ac5da95f8 | ||
|
|
911b39666d | ||
|
|
2dbc63809d | ||
|
|
a448c904d3 | ||
|
|
7b5f879305 | ||
|
|
76cf25228f | ||
|
|
bbe88c2a5e | ||
|
|
def4f3cb09 | ||
|
|
0d262ea9d4 | ||
|
|
fc2ca1646a | ||
|
|
2d7de216a7 | ||
|
|
8f2f9b8184 | ||
|
|
93a01938a4 | ||
|
|
70389521bf | ||
|
|
3f22ad4eac | ||
|
|
d99b9f7a70 | ||
|
|
5a125bf379 | ||
|
|
f7b64244b8 | ||
|
|
c2543289b7 | ||
|
|
9e977f2c70 | ||
|
|
e95ce2d390 | ||
|
|
503fbfc038 | ||
|
|
23fee438a9 | ||
|
|
1f9e369b73 | ||
|
|
403013b7bd | ||
|
|
e348c7b043 | ||
|
|
4db36d5ea9 | ||
|
|
aebbee681c | ||
|
|
2985ab3922 | ||
|
|
aa374944a1 | ||
|
|
84d14cad7f | ||
|
|
b9a14d5eb5 | ||
|
|
4514f08a42 | ||
|
|
2689f78925 | ||
|
|
85b63c16e9 | ||
|
|
4b387b5d77 | ||
|
|
fba6e5f065 | ||
|
|
095aab5f9d | ||
|
|
c60f203aab | ||
|
|
57c8d47ff3 | ||
|
|
e087ea5345 | ||
|
|
3c4bdebcda | ||
|
|
6f6f37ca24 | ||
|
|
649dd433d5 | ||
|
|
1aa5a07501 | ||
|
|
efe9938b33 | ||
|
|
1b64989909 | ||
|
|
b480b68e3e | ||
|
|
5294b492fc | ||
|
|
080fe4cf5f | ||
|
|
8b2f40390b | ||
|
|
3b63719fad | ||
|
|
061ae756ac | ||
|
|
862bd8ff07 | ||
|
|
742710443a | ||
|
|
015aeadf88 | ||
|
|
b8b654a160 | ||
|
|
3c5b5ca49b | ||
|
|
fb789d95ed | ||
|
|
2e6906c8d4 | ||
|
|
cc8d44bbd1 | ||
|
|
0ad56de6fc | ||
|
|
dedd7a5a41 | ||
|
|
44c961720c | ||
|
|
79b1d6df1b | ||
|
|
274ab2328e | ||
|
|
93ee900cb3 | ||
|
|
62474967c9 | ||
|
|
2cdc8d5f69 | ||
|
|
4863c94824 | ||
|
|
193332da74 | ||
|
|
9926296d35 | ||
|
|
bb6f8b9d57 | ||
|
|
780f6e8974 | ||
|
|
ab30d44184 | ||
|
|
e23f737fa7 | ||
|
|
b8e3bb8eb8 | ||
|
|
12574bca8b | ||
|
|
f16ea54b4f | ||
|
|
ad52bf608f | ||
|
|
46ee52f4ef | ||
|
|
88fb44bbba | ||
|
|
de5e626430 | ||
|
|
1bcdba1b4b | ||
|
|
a4353cf39d | ||
|
|
63192f2291 | ||
|
|
675b7ca7ba | ||
|
|
df5eb552a0 | ||
|
|
5017f4a2c7 | ||
|
|
92d3dccb94 | ||
|
|
2c38b5ee7b | ||
|
|
435bb50d29 | ||
|
|
005493bb5a | ||
|
|
838a4e4f7b | ||
|
|
bc47c80bbf | ||
|
|
aabaa30fa7 | ||
|
|
1ee39275fc |
@@ -639,6 +639,12 @@ omit =
|
||||
homeassistant/components/izone/climate.py
|
||||
homeassistant/components/izone/discovery.py
|
||||
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/entity.py
|
||||
homeassistant/components/justnimbus/sensor.py
|
||||
|
||||
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -207,7 +207,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2024.01.0
|
||||
uses: home-assistant/builder@2024.03.5
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2024.01.0
|
||||
uses: home-assistant/builder@2024.03.5
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -343,7 +343,7 @@ jobs:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3.4.0
|
||||
with:
|
||||
cosign-release: "v2.0.2"
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3.0.0
|
||||
|
||||
@@ -669,6 +669,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/jellyfin/ @j-stienstra @ctalkington
|
||||
/homeassistant/components/jewish_calendar/ @tsvi
|
||||
/tests/components/jewish_calendar/ @tsvi
|
||||
/homeassistant/components/juicenet/ @jesserockz
|
||||
/tests/components/juicenet/ @jesserockz
|
||||
/homeassistant/components/justnimbus/ @kvanzuijlen
|
||||
/tests/components/justnimbus/ @kvanzuijlen
|
||||
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
|
||||
|
||||
@@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
address = entry.unique_id
|
||||
|
||||
elevation = hass.config.elevation
|
||||
is_metric = hass.config.units is METRIC_SYSTEM
|
||||
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}"
|
||||
)
|
||||
|
||||
airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric)
|
||||
airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric)
|
||||
|
||||
async def _async_update_method() -> AirthingsDevice:
|
||||
"""Get data from Airthings BLE."""
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airthings-ble==0.6.1"]
|
||||
"requirements": ["airthings-ble==0.7.1"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
@@ -106,8 +105,8 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
|
||||
),
|
||||
"illuminance": SensorEntityDescription(
|
||||
key="illuminance",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
translation_key="illuminance",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
@@ -222,7 +221,7 @@ class AirthingsSensor(
|
||||
manufacturer=airthings_device.manufacturer,
|
||||
hw_version=airthings_device.hw_version,
|
||||
sw_version=airthings_device.sw_version,
|
||||
model=airthings_device.model,
|
||||
model=airthings_device.model.name,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
},
|
||||
"radon_longterm_level": {
|
||||
"name": "Radon longterm level"
|
||||
},
|
||||
"illuminance": {
|
||||
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==0.7.5"]
|
||||
"requirements": ["aioairzone==0.7.6"]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
options = ConnectionOptions(
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
True,
|
||||
)
|
||||
|
||||
airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options)
|
||||
|
||||
@@ -94,6 +94,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
ConnectionOptions(
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.3.8"]
|
||||
"requirements": ["aioairzone-cloud==0.4.5"]
|
||||
}
|
||||
|
||||
@@ -31,9 +31,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
# Ignore services that don't support usage data
|
||||
ignore_types = FETCH_TYPES + ["Hardware"]
|
||||
|
||||
try:
|
||||
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:
|
||||
raise ConfigEntryAuthFailed() from exc
|
||||
except ClientError as exc:
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["axis==49"],
|
||||
"requirements": ["axis==58"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_shc",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["boschshcpy"],
|
||||
"requirements": ["boschshcpy==0.2.75"],
|
||||
"requirements": ["boschshcpy==0.2.82"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
|
||||
from bring_api.bring import Bring
|
||||
from bring_api.exceptions import BringParseException, BringRequestException
|
||||
from bring_api.types import BringItemsResponse, BringList
|
||||
from bring_api.types import BringList, BringPurchase
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -20,8 +20,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class BringData(BringList):
|
||||
"""Coordinator data class."""
|
||||
|
||||
purchase_items: list[BringItemsResponse]
|
||||
recently_items: list[BringItemsResponse]
|
||||
purchase_items: list[BringPurchase]
|
||||
recently_items: list[BringPurchase]
|
||||
|
||||
|
||||
class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bring",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["bring-api==0.4.1"]
|
||||
"requirements": ["bring-api==0.5.6"]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import uuid
|
||||
|
||||
from bring_api.exceptions import BringRequestException
|
||||
from bring_api.types import BringItem, BringItemOperation
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
@@ -76,7 +78,7 @@ class BringTodoListEntity(
|
||||
return [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=item["itemId"],
|
||||
uid=item["uuid"],
|
||||
summary=item["itemId"],
|
||||
description=item["specification"] or "",
|
||||
status=TodoItemStatus.NEEDS_ACTION,
|
||||
@@ -85,7 +87,7 @@ class BringTodoListEntity(
|
||||
),
|
||||
*(
|
||||
TodoItem(
|
||||
uid=item["itemId"],
|
||||
uid=item["uuid"],
|
||||
summary=item["itemId"],
|
||||
description=item["specification"] or "",
|
||||
status=TodoItemStatus.COMPLETED,
|
||||
@@ -103,7 +105,10 @@ class BringTodoListEntity(
|
||||
"""Add an item to the To-do list."""
|
||||
try:
|
||||
await self.coordinator.bring.save_item(
|
||||
self.bring_list["listUuid"], item.summary, item.description or ""
|
||||
self.bring_list["listUuid"],
|
||||
item.summary,
|
||||
item.description or "",
|
||||
str(uuid.uuid4()),
|
||||
)
|
||||
except BringRequestException as e:
|
||||
raise HomeAssistantError("Unable to save todo item for bring") from e
|
||||
@@ -121,60 +126,69 @@ class BringTodoListEntity(
|
||||
|
||||
- Completed items will move to the "completed" section in home assistant todo
|
||||
list and get moved to the recently list in bring
|
||||
- Bring items do not have unique identifiers and are using the
|
||||
name/summery/title. Therefore the name is not to be changed! Should a name
|
||||
be changed anyway, a new item will be created instead and no update for
|
||||
this item is performed and on the next cloud pull update, it will get
|
||||
cleared and replaced seamlessly
|
||||
- Bring shows some odd behaviour when renaming items. This is because Bring
|
||||
did not have unique identifiers for items in the past and this is still
|
||||
a relic from it. Therefore the name is not to be changed! Should a name
|
||||
be changed anyway, the item will be deleted and a new item will be created
|
||||
instead and no update for this item is performed and on the next cloud pull
|
||||
update, it will get cleared and replaced seamlessly.
|
||||
"""
|
||||
|
||||
bring_list = self.bring_list
|
||||
|
||||
bring_purchase_item = next(
|
||||
(i for i in bring_list["purchase_items"] if i["itemId"] == item.uid),
|
||||
(i for i in bring_list["purchase_items"] if i["uuid"] == item.uid),
|
||||
None,
|
||||
)
|
||||
|
||||
bring_recently_item = next(
|
||||
(i for i in bring_list["recently_items"] if i["itemId"] == item.uid),
|
||||
(i for i in bring_list["recently_items"] if i["uuid"] == item.uid),
|
||||
None,
|
||||
)
|
||||
|
||||
current_item = bring_purchase_item or bring_recently_item
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert item.uid
|
||||
assert current_item
|
||||
|
||||
if item.status == TodoItemStatus.COMPLETED and bring_purchase_item:
|
||||
await self.coordinator.bring.complete_item(
|
||||
bring_list["listUuid"],
|
||||
item.uid,
|
||||
)
|
||||
|
||||
elif item.status == TodoItemStatus.NEEDS_ACTION and bring_recently_item:
|
||||
await self.coordinator.bring.save_item(
|
||||
bring_list["listUuid"],
|
||||
item.uid,
|
||||
)
|
||||
|
||||
elif item.summary == item.uid:
|
||||
if item.summary == current_item["itemId"]:
|
||||
try:
|
||||
await self.coordinator.bring.update_item(
|
||||
await self.coordinator.bring.batch_update_list(
|
||||
bring_list["listUuid"],
|
||||
item.uid,
|
||||
item.description or "",
|
||||
BringItem(
|
||||
itemId=item.summary,
|
||||
spec=item.description,
|
||||
uuid=item.uid,
|
||||
),
|
||||
BringItemOperation.ADD
|
||||
if item.status == TodoItemStatus.NEEDS_ACTION
|
||||
else BringItemOperation.COMPLETE,
|
||||
)
|
||||
except BringRequestException as e:
|
||||
raise HomeAssistantError("Unable to update todo item for bring") from e
|
||||
else:
|
||||
try:
|
||||
await self.coordinator.bring.remove_item(
|
||||
await self.coordinator.bring.batch_update_list(
|
||||
bring_list["listUuid"],
|
||||
item.uid,
|
||||
)
|
||||
await self.coordinator.bring.save_tem(
|
||||
bring_list["listUuid"],
|
||||
item.summary,
|
||||
item.description or "",
|
||||
[
|
||||
BringItem(
|
||||
itemId=current_item["itemId"],
|
||||
spec=item.description,
|
||||
uuid=item.uid,
|
||||
operation=BringItemOperation.REMOVE,
|
||||
),
|
||||
BringItem(
|
||||
itemId=item.summary,
|
||||
spec=item.description,
|
||||
uuid=str(uuid.uuid4()),
|
||||
operation=BringItemOperation.ADD
|
||||
if item.status == TodoItemStatus.NEEDS_ACTION
|
||||
else BringItemOperation.COMPLETE,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
except BringRequestException as e:
|
||||
raise HomeAssistantError("Unable to replace todo item for bring") from e
|
||||
|
||||
@@ -182,12 +196,21 @@ class BringTodoListEntity(
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Delete an item from the To-do list."""
|
||||
for uid in uids:
|
||||
try:
|
||||
await self.coordinator.bring.remove_item(
|
||||
self.bring_list["listUuid"], uid
|
||||
)
|
||||
except BringRequestException as e:
|
||||
raise HomeAssistantError("Unable to delete todo item for bring") from e
|
||||
|
||||
try:
|
||||
await self.coordinator.bring.batch_update_list(
|
||||
self.bring_list["listUuid"],
|
||||
[
|
||||
BringItem(
|
||||
itemId=uid,
|
||||
spec="",
|
||||
uuid=uid,
|
||||
)
|
||||
for uid in uids
|
||||
],
|
||||
BringItemOperation.REMOVE,
|
||||
)
|
||||
except BringRequestException as e:
|
||||
raise HomeAssistantError("Unable to delete todo item for bring") from e
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
brother = await Brother.create(
|
||||
host, printer_type=printer_type, snmp_engine=snmp_engine
|
||||
)
|
||||
except (ConnectionError, SnmpError) as error:
|
||||
except (ConnectionError, SnmpError, TimeoutError) as error:
|
||||
raise ConfigEntryNotReady from error
|
||||
|
||||
coordinator = BrotherDataUpdateCoordinator(hass, brother)
|
||||
|
||||
@@ -58,7 +58,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
except InvalidHost:
|
||||
errors[CONF_HOST] = "wrong_host"
|
||||
except ConnectionError:
|
||||
except (ConnectionError, TimeoutError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except SnmpError:
|
||||
errors["base"] = "snmp_error"
|
||||
@@ -88,7 +88,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await self.brother.async_update()
|
||||
except UnsupportedModelError:
|
||||
return self.async_abort(reason="unsupported_model")
|
||||
except (ConnectionError, SnmpError):
|
||||
except (ConnectionError, SnmpError, TimeoutError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
# Check if already configured
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["brother==4.0.0"],
|
||||
"requirements": ["brother==4.0.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_printer._tcp.local.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Config flow for BTHome Bluetooth integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
@@ -11,7 +12,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfo,
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
@@ -26,11 +27,11 @@ class Discovery:
|
||||
"""A discovered bluetooth device."""
|
||||
|
||||
title: str
|
||||
discovery_info: BluetoothServiceInfo
|
||||
discovery_info: BluetoothServiceInfoBleak
|
||||
device: DeviceData
|
||||
|
||||
|
||||
def _title(discovery_info: BluetoothServiceInfo, device: DeviceData) -> str:
|
||||
def _title(discovery_info: BluetoothServiceInfoBleak, device: DeviceData) -> str:
|
||||
return device.title or device.get_device_name() or discovery_info.name
|
||||
|
||||
|
||||
@@ -41,12 +42,12 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfo | None = None
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovered_device: DeviceData | None = None
|
||||
self._discovered_devices: dict[str, Discovery] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> FlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.5.0"]
|
||||
"requirements": ["bthome-ble==3.8.0"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Library for working with CalDAV api."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import caldav
|
||||
|
||||
@@ -13,20 +12,13 @@ async def async_get_calendars(
|
||||
"""Get all calendars that support the specified component."""
|
||||
|
||||
def _get_calendars() -> list[caldav.Calendar]:
|
||||
return client.principal().calendars()
|
||||
|
||||
calendars = await hass.async_add_executor_job(_get_calendars)
|
||||
components_results = await asyncio.gather(
|
||||
*[
|
||||
hass.async_add_executor_job(calendar.get_supported_components)
|
||||
for calendar in calendars
|
||||
return [
|
||||
calendar
|
||||
for calendar in client.principal().calendars()
|
||||
if component in calendar.get_supported_components()
|
||||
]
|
||||
)
|
||||
return [
|
||||
calendar
|
||||
for calendar, supported_components in zip(calendars, components_results)
|
||||
if component in supported_components
|
||||
]
|
||||
|
||||
return await hass.async_add_executor_job(_get_calendars)
|
||||
|
||||
|
||||
def get_attr_value(obj: caldav.CalendarObjectResource, attribute: str) -> str | None:
|
||||
|
||||
@@ -189,6 +189,11 @@ def _validate_rrule(value: Any) -> str:
|
||||
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_SCHEMA = vol.All(
|
||||
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("entity_id"): cv.entity_id,
|
||||
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,
|
||||
}
|
||||
)
|
||||
@@ -777,7 +784,9 @@ async def handle_calendar_event_delete(
|
||||
vol.Required("type"): "calendar/event/update",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
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.Required(CONF_EVENT): WEBSOCKET_EVENT_SCHEMA,
|
||||
}
|
||||
|
||||
@@ -391,6 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
|
||||
prefs = CameraPreferences(hass)
|
||||
await prefs.async_load()
|
||||
hass.data[DATA_CAMERA_PREFS] = prefs
|
||||
|
||||
hass.http.register_view(CameraImageView(component))
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.media_source.models import (
|
||||
PlayMedia,
|
||||
)
|
||||
from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
@@ -25,13 +26,20 @@ async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource:
|
||||
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(
|
||||
domain=DOMAIN,
|
||||
identifier=camera.entity_id,
|
||||
media_class=MediaClass.VIDEO,
|
||||
media_content_type=content_type,
|
||||
title=camera.name,
|
||||
title=title,
|
||||
thumbnail=f"/api/camera_proxy/{camera.entity_id}",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
@@ -89,7 +97,7 @@ class CameraMediaSource(MediaSource):
|
||||
async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None:
|
||||
stream_type = camera.frontend_stream_type
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -97,7 +105,7 @@ class CameraMediaSource(MediaSource):
|
||||
if stream_type != StreamType.HLS and not (await camera.stream_source()):
|
||||
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]
|
||||
results = await asyncio.gather(
|
||||
|
||||
@@ -29,6 +29,8 @@ class DynamicStreamSettings:
|
||||
class CameraPreferences:
|
||||
"""Handle camera preferences."""
|
||||
|
||||
_preload_prefs: dict[str, dict[str, bool | Orientation]]
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize camera prefs."""
|
||||
self._hass = hass
|
||||
@@ -41,6 +43,10 @@ class CameraPreferences:
|
||||
str, DynamicStreamSettings
|
||||
] = {}
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Initialize the camera preferences."""
|
||||
self._preload_prefs = await self._store.async_load() or {}
|
||||
|
||||
async def async_update(
|
||||
self,
|
||||
entity_id: str,
|
||||
@@ -63,9 +69,8 @@ class CameraPreferences:
|
||||
if preload_stream is not UNDEFINED:
|
||||
if dynamic_stream_settings:
|
||||
dynamic_stream_settings.preload_stream = preload_stream
|
||||
preload_prefs = await self._store.async_load() or {}
|
||||
preload_prefs[entity_id] = {PREF_PRELOAD_STREAM: preload_stream}
|
||||
await self._store.async_save(preload_prefs)
|
||||
self._preload_prefs[entity_id] = {PREF_PRELOAD_STREAM: preload_stream}
|
||||
await self._store.async_save(self._preload_prefs)
|
||||
|
||||
if orientation is not UNDEFINED:
|
||||
if (registry := er.async_get(self._hass)).async_get(entity_id):
|
||||
@@ -91,10 +96,10 @@ class CameraPreferences:
|
||||
# Get orientation setting from entity registry
|
||||
reg_entry = er.async_get(self._hass).async_get(entity_id)
|
||||
er_prefs: Mapping = reg_entry.options.get(DOMAIN, {}) if reg_entry else {}
|
||||
preload_prefs = await self._store.async_load() or {}
|
||||
settings = DynamicStreamSettings(
|
||||
preload_stream=cast(
|
||||
bool, preload_prefs.get(entity_id, {}).get(PREF_PRELOAD_STREAM, False)
|
||||
bool,
|
||||
self._preload_prefs.get(entity_id, {}).get(PREF_PRELOAD_STREAM, False),
|
||||
),
|
||||
orientation=er_prefs.get(PREF_ORIENTATION, Orientation.NO_TRANSFORM),
|
||||
)
|
||||
|
||||
@@ -24,9 +24,9 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Cast from a config entry."""
|
||||
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
|
||||
await home_assistant_cast.async_setup_ha_cast(hass, entry)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
|
||||
await async_process_integration_platforms(hass, DOMAIN, _register_cast_platform)
|
||||
return True
|
||||
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==14.0.0"],
|
||||
"requirements": ["PyChromecast==14.0.1"],
|
||||
"zeroconf": ["_googlecast._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
|
||||
|
||||
import pychromecast
|
||||
from pychromecast.controllers.homeassistant import HomeAssistantController
|
||||
@@ -18,6 +19,7 @@ from pychromecast.controllers.media import (
|
||||
)
|
||||
from pychromecast.controllers.multizone import MultizoneManager
|
||||
from pychromecast.controllers.receiver import VOLUME_CONTROL_TYPE_FIXED
|
||||
from pychromecast.error import PyChromecastError
|
||||
from pychromecast.quick_play import quick_play
|
||||
from pychromecast.socket_client import (
|
||||
CONNECTION_STATUS_CONNECTED,
|
||||
@@ -83,6 +85,34 @@ APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",)
|
||||
CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png"
|
||||
|
||||
|
||||
_CastDeviceT = TypeVar("_CastDeviceT", bound="CastDevice")
|
||||
_R = TypeVar("_R")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
_FuncType = Callable[Concatenate[_CastDeviceT, _P], _R]
|
||||
_ReturnFuncType = Callable[Concatenate[_CastDeviceT, _P], _R]
|
||||
|
||||
|
||||
def api_error(
|
||||
func: _FuncType[_CastDeviceT, _P, _R],
|
||||
) -> _ReturnFuncType[_CastDeviceT, _P, _R]:
|
||||
"""Handle PyChromecastError and reraise a HomeAssistantError."""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self: _CastDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
"""Wrap a CastDevice method."""
|
||||
try:
|
||||
return_value = func(self, *args, **kwargs)
|
||||
except PyChromecastError as err:
|
||||
raise HomeAssistantError(
|
||||
f"{self.__class__.__name__}.{func.__name__} Failed: {err}"
|
||||
) from err
|
||||
|
||||
return return_value
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@callback
|
||||
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
|
||||
"""Create a CastDevice entity or dynamic group from the chromecast object.
|
||||
@@ -476,6 +506,21 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
|
||||
return media_controller
|
||||
|
||||
@api_error
|
||||
def _quick_play(self, app_name: str, data: dict[str, Any]) -> None:
|
||||
"""Launch the app `app_name` and start playing media defined by `data`."""
|
||||
quick_play(self._get_chromecast(), app_name, data)
|
||||
|
||||
@api_error
|
||||
def _quit_app(self) -> None:
|
||||
"""Quit the currently running app."""
|
||||
self._get_chromecast().quit_app()
|
||||
|
||||
@api_error
|
||||
def _start_app(self, app_id: str) -> None:
|
||||
"""Start an app."""
|
||||
self._get_chromecast().start_app(app_id)
|
||||
|
||||
def turn_on(self) -> None:
|
||||
"""Turn on the cast device."""
|
||||
|
||||
@@ -486,52 +531,61 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
|
||||
if chromecast.app_id is not None:
|
||||
# Quit the previous app before starting splash screen or media player
|
||||
chromecast.quit_app()
|
||||
self._quit_app()
|
||||
|
||||
# The only way we can turn the Chromecast is on is by launching an app
|
||||
if chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST:
|
||||
app_data = {"media_id": CAST_SPLASH, "media_type": "image/png"}
|
||||
quick_play(chromecast, "default_media_receiver", app_data)
|
||||
self._quick_play("default_media_receiver", app_data)
|
||||
else:
|
||||
chromecast.start_app(pychromecast.config.APP_MEDIA_RECEIVER)
|
||||
self._start_app(pychromecast.config.APP_MEDIA_RECEIVER)
|
||||
|
||||
@api_error
|
||||
def turn_off(self) -> None:
|
||||
"""Turn off the cast device."""
|
||||
self._get_chromecast().quit_app()
|
||||
|
||||
@api_error
|
||||
def mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
self._get_chromecast().set_volume_muted(mute)
|
||||
|
||||
@api_error
|
||||
def set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
self._get_chromecast().set_volume(volume)
|
||||
|
||||
@api_error
|
||||
def media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
media_controller = self._media_controller()
|
||||
media_controller.play()
|
||||
|
||||
@api_error
|
||||
def media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
media_controller = self._media_controller()
|
||||
media_controller.pause()
|
||||
|
||||
@api_error
|
||||
def media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
media_controller = self._media_controller()
|
||||
media_controller.stop()
|
||||
|
||||
@api_error
|
||||
def media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
media_controller = self._media_controller()
|
||||
media_controller.queue_prev()
|
||||
|
||||
@api_error
|
||||
def media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
media_controller = self._media_controller()
|
||||
media_controller.queue_next()
|
||||
|
||||
@api_error
|
||||
def media_seek(self, position: float) -> None:
|
||||
"""Seek the media to a specific location."""
|
||||
media_controller = self._media_controller()
|
||||
@@ -644,7 +698,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
if "app_id" in app_data:
|
||||
app_id = app_data.pop("app_id")
|
||||
_LOGGER.info("Starting Cast app by ID %s", app_id)
|
||||
await self.hass.async_add_executor_job(chromecast.start_app, app_id)
|
||||
await self.hass.async_add_executor_job(self._start_app, app_id)
|
||||
if app_data:
|
||||
_LOGGER.warning(
|
||||
"Extra keys %s were ignored. Please use app_name to cast media",
|
||||
@@ -655,7 +709,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
app_name = app_data.pop("app_name")
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
quick_play, chromecast, app_name, app_data
|
||||
self._quick_play, app_name, app_data
|
||||
)
|
||||
except NotImplementedError:
|
||||
_LOGGER.error("App %s not supported", app_name)
|
||||
@@ -729,7 +783,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
app_data,
|
||||
)
|
||||
await self.hass.async_add_executor_job(
|
||||
quick_play, chromecast, "default_media_receiver", app_data
|
||||
self._quick_play, "default_media_receiver", app_data
|
||||
)
|
||||
|
||||
def _media_status(self):
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"fan_mode": {
|
||||
"default": "mdi:circle-medium",
|
||||
"state": {
|
||||
"auto": "mdi:fan-auto",
|
||||
"diffuse": "mdi:weather-windy",
|
||||
"focus": "mdi:target",
|
||||
"high": "mdi:speedometer",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity):
|
||||
"""Representation of a deCONZ light."""
|
||||
|
||||
TYPE = DOMAIN
|
||||
_attr_color_mode = ColorMode.UNKNOWN
|
||||
|
||||
def __init__(self, device: _LightDeviceT, gateway: DeconzGateway) -> None:
|
||||
"""Set up light."""
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==0.8.0",
|
||||
"aiodhcpwatcher==0.8.2",
|
||||
"aiodiscover==1.6.1",
|
||||
"cached_ipaddress==0.3.0"
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==1.19.1"],
|
||||
"requirements": ["pyenphase==1.19.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20240228.1"]
|
||||
"requirements": ["home-assistant-frontend==20240307.0"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ from gardena_bluetooth.exceptions import (
|
||||
)
|
||||
from gardena_bluetooth.parse import Characteristic, CharacteristicType
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -117,13 +116,7 @@ class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
self.coordinator.last_update_success
|
||||
and bluetooth.async_address_present(
|
||||
self.hass, self.coordinator.address, True
|
||||
)
|
||||
and self._attr_available
|
||||
)
|
||||
return self.coordinator.last_update_success and self._attr_available
|
||||
|
||||
|
||||
class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity):
|
||||
|
||||
@@ -229,11 +229,11 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
available = super().available
|
||||
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
|
||||
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)
|
||||
return available
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/calendar.google",
|
||||
"iot_class": "cloud_polling",
|
||||
"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.3"]
|
||||
}
|
||||
|
||||
@@ -2706,10 +2706,9 @@ class SensorStateTrait(_Trait):
|
||||
name = TRAIT_SENSOR_STATE
|
||||
commands: list[str] = []
|
||||
|
||||
def _air_quality_description_for_aqi(self, aqi):
|
||||
if aqi is None or aqi.isnumeric() is False:
|
||||
def _air_quality_description_for_aqi(self, aqi: float | None) -> str:
|
||||
if aqi is None or aqi < 0:
|
||||
return "unknown"
|
||||
aqi = int(aqi)
|
||||
if aqi <= 50:
|
||||
return "healthy"
|
||||
if aqi <= 100:
|
||||
@@ -2764,11 +2763,17 @@ class SensorStateTrait(_Trait):
|
||||
if device_class is None or data is None:
|
||||
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:
|
||||
sensor_data["currentSensorState"] = self._air_quality_description_for_aqi(
|
||||
self.state.state
|
||||
value
|
||||
)
|
||||
|
||||
return {"currentSensorStateData": [sensor_data]}
|
||||
|
||||
@@ -19,6 +19,7 @@ ATTR_HOMEASSISTANT = "homeassistant"
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE = "homeassistant_exclude_database"
|
||||
ATTR_INPUT = "input"
|
||||
ATTR_ISSUES = "issues"
|
||||
ATTR_MESSAGE = "message"
|
||||
ATTR_METHOD = "method"
|
||||
ATTR_PANELS = "panels"
|
||||
ATTR_PASSWORD = "password"
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.const import SERVER_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
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")
|
||||
|
||||
@@ -262,10 +262,7 @@ async def async_update_core(
|
||||
@bind_hass
|
||||
@_api_bool
|
||||
async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict:
|
||||
"""Apply a suggestion from supervisor's resolution center.
|
||||
|
||||
The caller of the function should handle HassioAPIError.
|
||||
"""
|
||||
"""Apply a suggestion from supervisor's resolution center."""
|
||||
hassio: HassIO = hass.data[DOMAIN]
|
||||
command = f"/resolution/suggestion/{suggestion_uuid}"
|
||||
return await hassio.send_command(command, timeout=None)
|
||||
@@ -576,7 +573,7 @@ class HassIO:
|
||||
raise HassioAPIError()
|
||||
|
||||
try:
|
||||
request = await self.websession.request(
|
||||
response = await self.websession.request(
|
||||
method,
|
||||
joined_url,
|
||||
json=payload,
|
||||
@@ -589,14 +586,23 @@ class HassIO:
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
)
|
||||
|
||||
if request.status != HTTPStatus.OK:
|
||||
_LOGGER.error("%s return code %d", command, request.status)
|
||||
if response.status != HTTPStatus.OK:
|
||||
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()
|
||||
|
||||
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:
|
||||
_LOGGER.error("Timeout on %s request", command)
|
||||
|
||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import logging
|
||||
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.event import async_call_later
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
@@ -35,6 +37,7 @@ from .const import (
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
PLACEHOLDER_KEY_REFERENCE,
|
||||
REQUEST_REFRESH_DELAY,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
SupervisorIssueContext,
|
||||
)
|
||||
@@ -302,12 +305,17 @@ class SupervisorIssues:
|
||||
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."""
|
||||
try:
|
||||
data = await self._client.get_resolution_info()
|
||||
except HassioAPIError as 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
|
||||
self.unhealthy_reasons = set(data[ATTR_UNHEALTHY])
|
||||
self.unsupported_reasons = set(data[ATTR_UNSUPPORTED])
|
||||
|
||||
@@ -18,7 +18,7 @@ from .const import (
|
||||
PLACEHOLDER_KEY_REFERENCE,
|
||||
SupervisorIssueContext,
|
||||
)
|
||||
from .handler import HassioAPIError, async_apply_suggestion
|
||||
from .handler import async_apply_suggestion
|
||||
from .issues import Issue, Suggestion
|
||||
|
||||
SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"}
|
||||
@@ -109,12 +109,9 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
|
||||
return self._async_form_for_suggestion(suggestion)
|
||||
|
||||
try:
|
||||
await async_apply_suggestion(self.hass, suggestion.uuid)
|
||||
except HassioAPIError:
|
||||
return self.async_abort(reason="apply_suggestion_fail")
|
||||
|
||||
return self.async_create_entry(data={})
|
||||
if await async_apply_suggestion(self.hass, suggestion.uuid):
|
||||
return self.async_create_entry(data={})
|
||||
return self.async_abort(reason="apply_suggestion_fail")
|
||||
|
||||
@staticmethod
|
||||
def _async_step(
|
||||
|
||||
@@ -21,7 +21,6 @@ from .const import (
|
||||
ATTR_DATA,
|
||||
ATTR_ENDPOINT,
|
||||
ATTR_METHOD,
|
||||
ATTR_RESULT,
|
||||
ATTR_SESSION_DATA_USER_ID,
|
||||
ATTR_TIMEOUT,
|
||||
ATTR_WS_EVENT,
|
||||
@@ -131,9 +130,6 @@ async def websocket_supervisor_api(
|
||||
payload=payload,
|
||||
source="core.websocket_api",
|
||||
)
|
||||
|
||||
if result.get(ATTR_RESULT) == "error":
|
||||
raise HassioAPIError(result.get("message"))
|
||||
except HassioAPIError as err:
|
||||
_LOGGER.error("Failed to to call %s - %s", msg[ATTR_ENDPOINT], err)
|
||||
connection.send_error(
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.43", "babel==2.13.1"]
|
||||
"requirements": ["holidays==0.44", "babel==2.13.1"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import contextlib
|
||||
import logging
|
||||
|
||||
import aiohomekit
|
||||
from aiohomekit.const import (
|
||||
BLE_TRANSPORT_SUPPORTED,
|
||||
COAP_TRANSPORT_SUPPORTED,
|
||||
IP_TRANSPORT_SUPPORTED,
|
||||
)
|
||||
from aiohomekit.exceptions import (
|
||||
AccessoryDisconnectedError,
|
||||
AccessoryNotFoundError,
|
||||
@@ -24,6 +29,15 @@ from .connection import HKDevice
|
||||
from .const import DOMAIN, KNOWN_DEVICES
|
||||
from .utils import async_get_controller
|
||||
|
||||
# Ensure all the controllers get imported in the executor
|
||||
# since they are loaded late.
|
||||
if BLE_TRANSPORT_SUPPORTED:
|
||||
from aiohomekit.controller import ble # noqa: F401
|
||||
if COAP_TRANSPORT_SUPPORTED:
|
||||
from aiohomekit.controller import coap # noqa: F401
|
||||
if IP_TRANSPORT_SUPPORTED:
|
||||
from aiohomekit.controller import ip # noqa: F401
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -57,7 +57,7 @@ async def async_setup_entry(
|
||||
event_type: EventType, resource: BehaviorInstance | LightLevel | Motion
|
||||
) -> None:
|
||||
"""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
|
||||
for item in controller:
|
||||
|
||||
@@ -269,10 +269,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
self._dynamic_mode_active = lights_in_dynamic_mode > 0
|
||||
self._attr_supported_color_modes = supported_color_modes
|
||||
# pick a winner for the current colormode
|
||||
if (
|
||||
lights_with_color_temp_support > 0
|
||||
and lights_in_colortemp_mode == lights_with_color_temp_support
|
||||
):
|
||||
if lights_with_color_temp_support > 0 and lights_in_colortemp_mode > 0:
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
elif lights_with_color_support > 0:
|
||||
self._attr_color_mode = ColorMode.XY
|
||||
|
||||
@@ -6,7 +6,7 @@ from aioautomower.session import AutomowerSession
|
||||
from aiohttp import ClientError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
@@ -17,7 +17,6 @@ from .coordinator import AutomowerDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LAWN_MOWER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
@@ -38,13 +37,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await api_api.async_get_access_token()
|
||||
except ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
coordinator = AutomowerDataUpdateCoordinator(hass, automower_api)
|
||||
coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
coordinator.client_listen(hass, entry, automower_api),
|
||||
"websocket_task",
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
@@ -52,8 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle unload of an entry."""
|
||||
coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
await coordinator.shutdown()
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import logging
|
||||
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.data_entry_flow import FlowResult
|
||||
@@ -27,7 +27,7 @@ class HusqvarnaConfigFlowHandler(
|
||||
"""Create an entry for the flow."""
|
||||
token = data[CONF_TOKEN]
|
||||
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
|
||||
last_name = structured_token.user.last_name
|
||||
await self.async_set_unique_id(user_id)
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
"""Data UpdateCoordinator for the Husqvarna Automower integration."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError
|
||||
from aioautomower.model import MowerAttributes
|
||||
from aioautomower.session import AutomowerSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
MAX_WS_RECONNECT_TIME = 600
|
||||
|
||||
|
||||
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
|
||||
"""Class to manage fetching Husqvarna data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, api: AsyncConfigEntryAuth) -> None:
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, api: AutomowerSession, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Initialize data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -35,13 +40,39 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
|
||||
await self.api.connect()
|
||||
self.api.register_data_callback(self.callback)
|
||||
self.ws_connected = True
|
||||
return await self.api.get_status()
|
||||
|
||||
async def shutdown(self, *_: Any) -> None:
|
||||
"""Close resources."""
|
||||
await self.api.close()
|
||||
try:
|
||||
return await self.api.get_status()
|
||||
except ApiException as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
@callback
|
||||
def callback(self, ws_data: dict[str, MowerAttributes]) -> None:
|
||||
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
|
||||
self.async_set_updated_data(ws_data)
|
||||
|
||||
async def client_listen(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
automower_client: AutomowerSession,
|
||||
reconnect_time: int = 2,
|
||||
) -> None:
|
||||
"""Listen with the client."""
|
||||
try:
|
||||
await automower_client.auth.websocket_connect()
|
||||
reconnect_time = 2
|
||||
await automower_client.start_listening()
|
||||
except HusqvarnaWSServerHandshakeError as err:
|
||||
_LOGGER.debug(
|
||||
"Failed to connect to websocket. Trying to reconnect: %s", err
|
||||
)
|
||||
|
||||
if not hass.is_stopping:
|
||||
await asyncio.sleep(reconnect_time)
|
||||
reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME)
|
||||
await self.client_listen(
|
||||
hass=hass,
|
||||
entry=entry,
|
||||
automower_client=automower_client,
|
||||
reconnect_time=reconnect_time,
|
||||
)
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower",
|
||||
"iot_class": "cloud_push",
|
||||
"requirements": ["aioautomower==2024.2.7"]
|
||||
"loggers": ["aioautomower"],
|
||||
"requirements": ["aioautomower==2024.3.3"]
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
exists_fn=lambda data: data.statistics.total_charging_time is not None,
|
||||
value_fn=lambda data: data.statistics.total_charging_time,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
@@ -79,6 +80,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
exists_fn=lambda data: data.statistics.total_cutting_time is not None,
|
||||
value_fn=lambda data: data.statistics.total_cutting_time,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
@@ -89,6 +91,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
exists_fn=lambda data: data.statistics.total_running_time is not None,
|
||||
value_fn=lambda data: data.statistics.total_running_time,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
@@ -99,6 +102,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
exists_fn=lambda data: data.statistics.total_searching_time is not None,
|
||||
value_fn=lambda data: data.statistics.total_searching_time,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
@@ -107,6 +111,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
icon="mdi:battery-sync-outline",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
exists_fn=lambda data: data.statistics.number_of_charging_cycles is not None,
|
||||
value_fn=lambda data: data.statistics.number_of_charging_cycles,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
@@ -115,6 +120,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
icon="mdi:counter",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
exists_fn=lambda data: data.statistics.number_of_collisions is not None,
|
||||
value_fn=lambda data: data.statistics.number_of_collisions,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
@@ -125,6 +131,7 @@ SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
suggested_unit_of_measurement=UnitOfLength.KILOMETERS,
|
||||
exists_fn=lambda data: data.statistics.total_drive_distance is not None,
|
||||
value_fn=lambda data: data.statistics.total_drive_distance,
|
||||
),
|
||||
AutomowerSensorEntityDescription(
|
||||
|
||||
@@ -52,7 +52,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.ERROR,
|
||||
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)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydrawise"],
|
||||
"requirements": ["pydrawise==2024.2.0"]
|
||||
"requirements": ["pydrawise==2024.3.0"]
|
||||
}
|
||||
|
||||
28
homeassistant/components/ipp/diagnostics.py
Normal file
28
homeassistant/components/ipp/diagnostics.py
Normal 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(),
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["deepmerge", "pyipp"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyipp==0.14.5"],
|
||||
"requirements": ["pyipp==0.15.0"],
|
||||
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -149,7 +149,9 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity):
|
||||
media_content_type = CONTENT_TYPE_MAP.get(self.now_playing["Type"], None)
|
||||
media_content_id = self.now_playing["Id"]
|
||||
media_title = self.now_playing["Name"]
|
||||
media_duration = int(self.now_playing["RunTimeTicks"] / 10000000)
|
||||
|
||||
if "RunTimeTicks" in self.now_playing:
|
||||
media_duration = int(self.now_playing["RunTimeTicks"] / 10000000)
|
||||
|
||||
if media_content_type == MediaType.EPISODE:
|
||||
media_content_type = MediaType.TVSHOW
|
||||
|
||||
@@ -1,37 +1,107 @@
|
||||
"""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.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:
|
||||
"""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,
|
||||
DOMAIN,
|
||||
DOMAIN,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="integration_removed",
|
||||
translation_placeholders={
|
||||
"entries": "/config/integrations/integration/juicenet",
|
||||
},
|
||||
_LOGGER,
|
||||
name="JuiceNet",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if all(
|
||||
config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
for config_entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.entry_id != entry.entry_id
|
||||
):
|
||||
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
|
||||
|
||||
return True
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
@@ -1,11 +1,77 @@
|
||||
"""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):
|
||||
"""Handle a config flow for JuiceNet."""
|
||||
|
||||
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."""
|
||||
|
||||
6
homeassistant/components/juicenet/const.py
Normal file
6
homeassistant/components/juicenet/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants used by the JuiceNet component."""
|
||||
|
||||
DOMAIN = "juicenet"
|
||||
|
||||
JUICENET_API = "juicenet_api"
|
||||
JUICENET_COORDINATOR = "juicenet_coordinator"
|
||||
19
homeassistant/components/juicenet/device.py
Normal file
19
homeassistant/components/juicenet/device.py
Normal 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
|
||||
34
homeassistant/components/juicenet/entity.py
Normal file
34
homeassistant/components/juicenet/entity.py
Normal 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,
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"domain": "juicenet",
|
||||
"name": "JuiceNet",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@jesserockz"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/juicenet",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": []
|
||||
"loggers": ["pyjuicenet"],
|
||||
"requirements": ["python-juicenet==1.1.0"]
|
||||
}
|
||||
|
||||
99
homeassistant/components/juicenet/number.py
Normal file
99
homeassistant/components/juicenet/number.py
Normal 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)
|
||||
116
homeassistant/components/juicenet/sensor.py
Normal file
116
homeassistant/components/juicenet/sensor.py
Normal 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)
|
||||
@@ -1,8 +1,41 @@
|
||||
{
|
||||
"issues": {
|
||||
"integration_removed": {
|
||||
"title": "The JuiceNet integration has been removed",
|
||||
"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})."
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
homeassistant/components/juicenet/switch.py
Normal file
49
homeassistant/components/juicenet/switch.py
Normal 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)
|
||||
@@ -11,7 +11,7 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"xknx==2.12.1",
|
||||
"xknx==2.12.2",
|
||||
"xknxproject==3.7.0",
|
||||
"knx-frontend==2024.1.20.105944"
|
||||
]
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==7.0.0"]
|
||||
"requirements": ["ical==7.0.3"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==7.0.0"]
|
||||
"requirements": ["ical==7.0.3"]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_TODO_LIST_NAME, DOMAIN
|
||||
from .store import LocalTodoListStore
|
||||
@@ -124,6 +125,9 @@ class LocalTodoListEntity(TodoListEntity):
|
||||
self._attr_name = name.capitalize()
|
||||
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:
|
||||
"""Update entity state based on the local To-do items."""
|
||||
todo_items = []
|
||||
@@ -147,20 +151,20 @@ class LocalTodoListEntity(TodoListEntity):
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Add an item to the To-do list."""
|
||||
todo = _convert_item(item)
|
||||
TodoStore(self._calendar).add(todo)
|
||||
self._new_todo_store().add(todo)
|
||||
await self.async_save()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update an item to the To-do list."""
|
||||
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_update_ha_state(force_refresh=True)
|
||||
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Delete an item from the To-do list."""
|
||||
store = TodoStore(self._calendar)
|
||||
store = self._new_todo_store()
|
||||
for uid in uids:
|
||||
store.delete(uid)
|
||||
await self.async_save()
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/loqed",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["loqedAPI==2.1.8"],
|
||||
"requirements": ["loqedAPI==2.1.10"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/luci",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["openwrt_luci_rpc"],
|
||||
"requirements": ["openwrt-luci-rpc==1.1.16"]
|
||||
"requirements": ["openwrt-luci-rpc==1.1.17"]
|
||||
}
|
||||
|
||||
@@ -148,7 +148,10 @@ async def async_resolve_media(
|
||||
raise Unresolvable("Media Source not loaded")
|
||||
|
||||
if target_media_player is UNDEFINED:
|
||||
report("calls media_source.async_resolve_media without passing an entity_id")
|
||||
report(
|
||||
"calls media_source.async_resolve_media without passing an entity_id",
|
||||
{DOMAIN},
|
||||
)
|
||||
target_media_player = None
|
||||
|
||||
try:
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
@@ -41,9 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
try:
|
||||
await api.async_initialize()
|
||||
except MinecraftServerAddressError as error:
|
||||
raise ConfigEntryError(
|
||||
f"Server address in configuration entry is invalid: {error}"
|
||||
) from error
|
||||
raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
|
||||
|
||||
# Create coordinator instance.
|
||||
coordinator = MinecraftServerCoordinator(hass, entry.data[CONF_NAME], api)
|
||||
|
||||
@@ -134,12 +134,11 @@ class MjpegCamera(Camera):
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return a still image response from the camera."""
|
||||
# DigestAuth is not supported
|
||||
if (
|
||||
self._authentication == HTTP_DIGEST_AUTHENTICATION
|
||||
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)
|
||||
try:
|
||||
@@ -157,15 +156,17 @@ class MjpegCamera(Camera):
|
||||
|
||||
return None
|
||||
|
||||
def _get_digest_auth(self) -> httpx.DigestAuth:
|
||||
"""Return a DigestAuth object."""
|
||||
def _get_httpx_auth(self) -> httpx.Auth:
|
||||
"""Return a httpx auth object."""
|
||||
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."""
|
||||
client = get_async_client(self.hass, verify_ssl=self._verify_ssl)
|
||||
auth = self._get_digest_auth()
|
||||
auth = self._get_httpx_auth()
|
||||
try:
|
||||
if self._still_image_url:
|
||||
# Fallback to MJPEG stream if still image URL is not available
|
||||
@@ -196,7 +197,7 @@ class MjpegCamera(Camera):
|
||||
) -> web.StreamResponse | None:
|
||||
"""Generate an HTTP MJPEG stream from the camera using digest authentication."""
|
||||
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:
|
||||
response = web.StreamResponse(headers=stream.headers)
|
||||
await response.prepare(request)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pymodbus"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["pymodbus==3.6.4"]
|
||||
"requirements": ["pymodbus==3.6.6"]
|
||||
}
|
||||
|
||||
@@ -301,16 +301,17 @@ def check_config(config: dict) -> dict:
|
||||
|
||||
def validate_entity(
|
||||
hub_name: str,
|
||||
component: str,
|
||||
entity: dict,
|
||||
minimum_scan_interval: int,
|
||||
ent_names: set,
|
||||
ent_addr: set,
|
||||
) -> bool:
|
||||
"""Validate entity."""
|
||||
name = entity[CONF_NAME]
|
||||
addr = str(entity[CONF_ADDRESS])
|
||||
name = f"{component}.{entity[CONF_NAME]}"
|
||||
addr = f"{hub_name}{entity[CONF_ADDRESS]}"
|
||||
scan_interval = entity.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
if scan_interval < 5:
|
||||
if 0 < scan_interval < 5:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s %s scan_interval(%d) is lower than 5 seconds, "
|
||||
@@ -335,11 +336,15 @@ def check_config(config: dict) -> dict:
|
||||
loc_addr: set[str] = {addr}
|
||||
|
||||
if CONF_TARGET_TEMP in entity:
|
||||
loc_addr.add(f"{entity[CONF_TARGET_TEMP]}_{inx}")
|
||||
loc_addr.add(f"{hub_name}{entity[CONF_TARGET_TEMP]}_{inx}")
|
||||
if CONF_HVAC_MODE_REGISTER in entity:
|
||||
loc_addr.add(f"{entity[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]}_{inx}")
|
||||
loc_addr.add(
|
||||
f"{hub_name}{entity[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]}_{inx}"
|
||||
)
|
||||
if CONF_FAN_MODE_REGISTER in entity:
|
||||
loc_addr.add(f"{entity[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]}_{inx}")
|
||||
loc_addr.add(
|
||||
f"{hub_name}{entity[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]}_{inx}"
|
||||
)
|
||||
|
||||
dup_addrs = ent_addr.intersection(loc_addr)
|
||||
if len(dup_addrs) > 0:
|
||||
@@ -364,15 +369,18 @@ def check_config(config: dict) -> dict:
|
||||
if not validate_modbus(hub, hub_name_inx):
|
||||
del config[hub_inx]
|
||||
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:
|
||||
continue
|
||||
no_entities = False
|
||||
entity_inx = 0
|
||||
entities = hub[conf_key]
|
||||
minimum_scan_interval = 9999
|
||||
while entity_inx < len(entities):
|
||||
if not validate_entity(
|
||||
hub[CONF_NAME],
|
||||
component,
|
||||
entities[entity_inx],
|
||||
minimum_scan_interval,
|
||||
ent_names,
|
||||
@@ -381,7 +389,11 @@ def check_config(config: dict) -> dict:
|
||||
del entities[entity_inx]
|
||||
else:
|
||||
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:
|
||||
hub[CONF_TIMEOUT] = minimum_scan_interval - 1
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -165,9 +165,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except NotionError as err:
|
||||
raise ConfigEntryNotReady("Config entry failed to load") from err
|
||||
|
||||
# Always update the config entry with the latest refresh token and user UUID:
|
||||
entry_updates["data"][CONF_REFRESH_TOKEN] = client.refresh_token
|
||||
entry_updates["data"][CONF_USER_UUID] = client.user_uuid
|
||||
# Update the Notion user UUID and refresh token if they've changed:
|
||||
for key, value in (
|
||||
(CONF_REFRESH_TOKEN, client.refresh_token),
|
||||
(CONF_USER_UUID, client.user_uuid),
|
||||
):
|
||||
if entry.data[key] == value:
|
||||
continue
|
||||
entry_updates["data"][key] = value
|
||||
|
||||
hass.config_entries.async_update_entry(entry, **entry_updates)
|
||||
|
||||
@callback
|
||||
def async_save_refresh_token(refresh_token: str) -> None:
|
||||
@@ -180,8 +187,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Create a callback to save the refresh token when it changes:
|
||||
entry.async_on_unload(client.add_refresh_token_callback(async_save_refresh_token))
|
||||
|
||||
hass.config_entries.async_update_entry(entry, **entry_updates)
|
||||
|
||||
async def async_update() -> NotionData:
|
||||
"""Get the latest data from the Notion API."""
|
||||
data = NotionData(hass=hass, entry=entry)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aionotion"],
|
||||
"requirements": ["aionotion==2024.02.2"]
|
||||
"requirements": ["aionotion==2024.03.0"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"name": "Numato USB GPIO Expander",
|
||||
"codeowners": ["@clssn"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/numato",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["numato_gpio"],
|
||||
"requirements": ["numato-gpio==0.10.0"]
|
||||
"requirements": ["numato-gpio==0.12.0"]
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ class OneWireBinarySensor(OneWireEntity, BinarySensorEntity):
|
||||
entity_description: OneWireBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if sensor is on."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return bool(self._state)
|
||||
|
||||
@@ -204,8 +204,10 @@ class OneWireSwitch(OneWireEntity, SwitchEntity):
|
||||
entity_description: OneWireSwitchEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor is on."""
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if switch is on."""
|
||||
if self._state is None:
|
||||
return None
|
||||
return bool(self._state)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz==1.13.7"],
|
||||
"requirements": ["pyoverkiz==1.13.8"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_kizbox._tcp.local.",
|
||||
|
||||
@@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = (
|
||||
key="record_distance",
|
||||
translation_key="record_distance",
|
||||
icon="mdi:map-marker-distance",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,11 +11,10 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import CONF_SERIAL_NUMBER
|
||||
from .coordinator import RainbirdData
|
||||
from .coordinator import RainbirdData, async_create_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,9 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
clientsession = async_create_clientsession()
|
||||
entry.async_on_unload(clientsession.close)
|
||||
controller = AsyncRainbirdController(
|
||||
AsyncRainbirdClient(
|
||||
async_get_clientsession(hass),
|
||||
clientsession,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import (
|
||||
@@ -30,6 +29,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
TIMEOUT_SECONDS,
|
||||
)
|
||||
from .coordinator import async_create_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,9 +101,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
Raises a ConfigFlowError on failure.
|
||||
"""
|
||||
clientsession = async_create_clientsession()
|
||||
controller = AsyncRainbirdController(
|
||||
AsyncRainbirdClient(
|
||||
async_get_clientsession(self.hass),
|
||||
clientsession,
|
||||
host,
|
||||
password,
|
||||
)
|
||||
@@ -124,6 +125,8 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
f"Error connecting to Rain Bird controller: {str(err)}",
|
||||
"cannot_connect",
|
||||
) from err
|
||||
finally:
|
||||
await clientsession.close()
|
||||
|
||||
async def async_finish(
|
||||
self,
|
||||
|
||||
@@ -9,6 +9,7 @@ from functools import cached_property
|
||||
import logging
|
||||
from typing import TypeVar
|
||||
|
||||
import aiohttp
|
||||
from pyrainbird.async_client import (
|
||||
AsyncRainbirdController,
|
||||
RainbirdApiException,
|
||||
@@ -18,6 +19,7 @@ from pyrainbird.data import ModelAndVersion, Schedule
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -28,6 +30,13 @@ UPDATE_INTERVAL = datetime.timedelta(minutes=1)
|
||||
# changes, so we refresh it less often.
|
||||
CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15)
|
||||
|
||||
# The valves state are not immediately reflected after issuing a command. We add
|
||||
# small delay to give additional time to reflect the new state.
|
||||
DEBOUNCER_COOLDOWN = 5
|
||||
|
||||
# Rainbird devices can only accept a single request at a time
|
||||
CONECTION_LIMIT = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
@@ -43,6 +52,13 @@ class RainbirdDeviceState:
|
||||
rain_delay: int
|
||||
|
||||
|
||||
def async_create_clientsession() -> aiohttp.ClientSession:
|
||||
"""Create a rainbird async_create_clientsession with a connection limit."""
|
||||
return aiohttp.ClientSession(
|
||||
connector=aiohttp.TCPConnector(limit=CONECTION_LIMIT),
|
||||
)
|
||||
|
||||
|
||||
class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
|
||||
"""Coordinator for rainbird API calls."""
|
||||
|
||||
@@ -60,6 +76,9 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
|
||||
_LOGGER,
|
||||
name=name,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass, _LOGGER, cooldown=DEBOUNCER_COOLDOWN, immediate=False
|
||||
),
|
||||
)
|
||||
self._controller = controller
|
||||
self._unique_id = unique_id
|
||||
|
||||
@@ -103,6 +103,10 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity)
|
||||
except RainbirdApiException as err:
|
||||
raise HomeAssistantError("Rain Bird device failure") from err
|
||||
|
||||
# The device reflects the old state for a few moments. Update the
|
||||
# state manually and trigger a refresh after a short debounced delay.
|
||||
self.coordinator.data.active_zones.add(self._zone)
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
@@ -115,6 +119,11 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity)
|
||||
) from err
|
||||
except RainbirdApiException as err:
|
||||
raise HomeAssistantError("Rain Bird device failure") from err
|
||||
|
||||
# The device reflects the old state for a few moments. Update the
|
||||
# state manually and trigger a refresh after a short debounced delay.
|
||||
self.coordinator.data.active_zones.remove(self._zone)
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
|
||||
@@ -132,16 +132,27 @@ class RAVEnDataCoordinator(DataUpdateCoordinator):
|
||||
)
|
||||
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]:
|
||||
try:
|
||||
device = await self._get_device()
|
||||
async with asyncio.timeout(5):
|
||||
return await _get_all_data(device, self.entry.data[CONF_MAC])
|
||||
except RAVEnConnectionError as err:
|
||||
if self._raven_device:
|
||||
await self._raven_device.close()
|
||||
self._raven_device = None
|
||||
await self._cleanup_device()
|
||||
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:
|
||||
if self._raven_device is not None:
|
||||
@@ -149,15 +160,14 @@ class RAVEnDataCoordinator(DataUpdateCoordinator):
|
||||
|
||||
device = RAVEnSerialDevice(self.entry.data[CONF_DEVICE])
|
||||
|
||||
async with asyncio.timeout(5):
|
||||
await device.open()
|
||||
|
||||
try:
|
||||
try:
|
||||
async with asyncio.timeout(5):
|
||||
await device.open()
|
||||
await device.synchronize()
|
||||
self._device_info = await device.get_device_info()
|
||||
except Exception:
|
||||
await device.close()
|
||||
raise
|
||||
except:
|
||||
await device.close()
|
||||
raise
|
||||
|
||||
self._raven_device = device
|
||||
return device
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/rainforest_raven",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["aioraven==0.5.1"],
|
||||
"requirements": ["aioraven==0.5.2"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "0403",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"title": "Random sensor"
|
||||
},
|
||||
"user": {
|
||||
"description": "This helper allow you to create a helper that emits a random value.",
|
||||
"description": "This helper allows you to create a helper that emits a random value.",
|
||||
"menu_options": {
|
||||
"binary_sensor": "Random binary sensor",
|
||||
"sensor": "Random sensor"
|
||||
|
||||
@@ -924,7 +924,7 @@ class Recorder(threading.Thread):
|
||||
# that is pending before running the task
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(task, RecorderTask)
|
||||
if not task.commit_before:
|
||||
if task.commit_before:
|
||||
self._commit_event_session_or_retry()
|
||||
return task.run(self)
|
||||
except exc.DatabaseError as err:
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.start import async_at_start
|
||||
|
||||
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__)
|
||||
|
||||
@@ -61,7 +61,10 @@ def update_states_metadata(
|
||||
)
|
||||
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):
|
||||
_LOGGER.warning(
|
||||
"Cannot migrate history for entity_id `%s` to `%s` "
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
import contextlib
|
||||
import dataclasses
|
||||
from datetime import datetime, timedelta
|
||||
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.engine.row import Row
|
||||
from sqlalchemy.exc import SQLAlchemyError, StatementError
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
||||
import voluptuous as vol
|
||||
@@ -72,6 +71,7 @@ from .models import (
|
||||
from .util import (
|
||||
execute,
|
||||
execute_stmt_lambda_element,
|
||||
filter_unique_constraint_integrity_error,
|
||||
get_instance,
|
||||
retryable_database_job,
|
||||
session_scope,
|
||||
@@ -454,7 +454,9 @@ def compile_missing_statistics(instance: Recorder) -> bool:
|
||||
|
||||
with session_scope(
|
||||
session=instance.get_session(),
|
||||
exception_filter=_filter_unique_constraint_integrity_error(instance),
|
||||
exception_filter=filter_unique_constraint_integrity_error(
|
||||
instance, "statistic"
|
||||
),
|
||||
) as session:
|
||||
# Find the newest statistics run, if any
|
||||
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
|
||||
with session_scope(
|
||||
session=instance.get_session(),
|
||||
exception_filter=_filter_unique_constraint_integrity_error(instance),
|
||||
exception_filter=filter_unique_constraint_integrity_error(
|
||||
instance, "statistic"
|
||||
),
|
||||
) as session:
|
||||
modified_statistic_ids = _compile_statistics(
|
||||
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:
|
||||
with session_scope(
|
||||
session=instance.get_session(),
|
||||
exception_filter=_filter_unique_constraint_integrity_error(instance),
|
||||
exception_filter=filter_unique_constraint_integrity_error(
|
||||
instance, "statistic"
|
||||
),
|
||||
) as session:
|
||||
statistics_meta_manager.update_statistic_id(
|
||||
session, DOMAIN, statistic_id, new_statistic_id
|
||||
@@ -2246,54 +2252,6 @@ def async_add_external_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(
|
||||
instance: Recorder,
|
||||
session: Session,
|
||||
@@ -2397,7 +2355,9 @@ def import_statistics(
|
||||
|
||||
with session_scope(
|
||||
session=instance.get_session(),
|
||||
exception_filter=_filter_unique_constraint_integrity_error(instance),
|
||||
exception_filter=filter_unique_constraint_integrity_error(
|
||||
instance, "statistic"
|
||||
),
|
||||
) as session:
|
||||
return _import_statistics_with_session(
|
||||
instance, session, metadata, statistics, table
|
||||
|
||||
@@ -307,11 +307,18 @@ class StatisticsMetaManager:
|
||||
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(
|
||||
(StatisticsMeta.statistic_id == old_statistic_id)
|
||||
& (StatisticsMeta.source == source)
|
||||
).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:
|
||||
"""Clear statistics for a list of statistic_ids.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Collection, Generator, Iterable, Sequence
|
||||
import contextlib
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime, timedelta
|
||||
import functools
|
||||
@@ -21,7 +22,7 @@ import ciso8601
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Result, Row
|
||||
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.session import Session
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"requirements": ["reolink-aio==0.8.8"]
|
||||
"requirements": ["reolink-aio==0.8.9"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user