forked from home-assistant/core
Compare commits
109 Commits
2022.3.0b5
...
2022.3.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49edaf2f68 | ||
|
|
2be9798fb8 | ||
|
|
3bf0a64e21 | ||
|
|
23e9aa6ad2 | ||
|
|
a8e1f57058 | ||
|
|
caee432901 | ||
|
|
df5c09e483 | ||
|
|
38eb007f63 | ||
|
|
7dd9bfa92f | ||
|
|
54b7f13a54 | ||
|
|
774f2b9b82 | ||
|
|
bc14385317 | ||
|
|
9352ed1286 | ||
|
|
f7fd781a27 | ||
|
|
b1153720c0 | ||
|
|
27d275e6f7 | ||
|
|
1191c095f8 | ||
|
|
b86d115764 | ||
|
|
479a230da7 | ||
|
|
7aecd69e3b | ||
|
|
69587dd50a | ||
|
|
6d8bd6af4d | ||
|
|
31b19e09b5 | ||
|
|
a42ba9e10a | ||
|
|
a285478cf8 | ||
|
|
c95d55e6d6 | ||
|
|
c0860931b3 | ||
|
|
898af3e04c | ||
|
|
3de341099f | ||
|
|
7fb76c68bb | ||
|
|
7de5e070fb | ||
|
|
1bfb01e0d1 | ||
|
|
ca664ab5a5 | ||
|
|
5a39e63d25 | ||
|
|
c608cafebd | ||
|
|
07e70c81b0 | ||
|
|
cad397d6a7 | ||
|
|
c22af2c82a | ||
|
|
f5b6d93706 | ||
|
|
28b3edf6b2 | ||
|
|
737c502e94 | ||
|
|
a1abcbc7eb | ||
|
|
b09ab2dafb | ||
|
|
4e6fc3615b | ||
|
|
580c998552 | ||
|
|
97ba17d1ec | ||
|
|
8d7cdceb75 | ||
|
|
dfa1c3abb3 | ||
|
|
c807c57a9b | ||
|
|
f4ec7e0902 | ||
|
|
814c96834e | ||
|
|
87492e6b3e | ||
|
|
4aaafb0a99 | ||
|
|
2aecdd3d6d | ||
|
|
76336df91a | ||
|
|
88e0380aa2 | ||
|
|
10a2c97cab | ||
|
|
92c3c08a10 | ||
|
|
4f8b69d985 | ||
|
|
f5aaf44e50 | ||
|
|
f3c85b3459 | ||
|
|
d7348718e0 | ||
|
|
2a6d5ea7bd | ||
|
|
5ae83e3c40 | ||
|
|
5657a9e6bd | ||
|
|
b290e62170 | ||
|
|
679ddbd1be | ||
|
|
b54652a849 | ||
|
|
24013ad94c | ||
|
|
9849b86a84 | ||
|
|
8bbf55c85d | ||
|
|
0541c708da | ||
|
|
ba40d62081 | ||
|
|
73765a1f29 | ||
|
|
b5b945ab4d | ||
|
|
d361643500 | ||
|
|
eff7a12557 | ||
|
|
63f8e9ee08 | ||
|
|
ee0bdaa2de | ||
|
|
48d9e9a83c | ||
|
|
8de94f3b5c | ||
|
|
d7c480f2d8 | ||
|
|
0349d7d09d | ||
|
|
be19a2e2ab | ||
|
|
b9f44eec0a | ||
|
|
9db56a8119 | ||
|
|
ddf7efd937 | ||
|
|
da4f4f641d | ||
|
|
288270ac08 | ||
|
|
092b973067 | ||
|
|
9aba0ba990 | ||
|
|
4668720f02 | ||
|
|
274e4d5558 | ||
|
|
94fd7ec028 | ||
|
|
c81ccaebd3 | ||
|
|
4c0ba7cd77 | ||
|
|
1ebb4cf395 | ||
|
|
17bc8c64f8 | ||
|
|
fa01715bbb | ||
|
|
99322e2658 | ||
|
|
9a306e2a89 | ||
|
|
47812c6b91 | ||
|
|
40d72b3188 | ||
|
|
b31e570ec7 | ||
|
|
768a031128 | ||
|
|
f1620cbb2e | ||
|
|
aeac31c926 | ||
|
|
26203e9924 | ||
|
|
d766b17323 |
117
.github/workflows/builder.yml
vendored
117
.github/workflows/builder.yml
vendored
@@ -122,13 +122,13 @@ jobs:
|
||||
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -187,13 +187,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1.13.0
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -243,22 +243,30 @@ jobs:
|
||||
channel: beta
|
||||
|
||||
publish_container:
|
||||
name: Publish meta container
|
||||
name: Publish meta container for ${{ matrix.registry }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry:
|
||||
- "ghcr.io/home-assistant"
|
||||
- "homeassistant"
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2.4.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1.13.0
|
||||
if: matrix.registry == 'homeassistant'
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1.13.0
|
||||
if: matrix.registry == 'ghcr.io/home-assistant'
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -273,38 +281,37 @@ jobs:
|
||||
export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
|
||||
function create_manifest() {
|
||||
local docker_reg=${1}
|
||||
local tag_l=${2}
|
||||
local tag_r=${3}
|
||||
local tag_l=${1}
|
||||
local tag_r=${2}
|
||||
|
||||
docker manifest create "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/amd64-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/i386-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/armhf-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/armv7-homeassistant:${tag_r}" \
|
||||
"${docker_reg}/aarch64-homeassistant:${tag_r}"
|
||||
docker manifest create "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/i386-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \
|
||||
"${{ matrix.registry }}/aarch64-homeassistant:${tag_r}"
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/amd64-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/amd64-homeassistant:${tag_r}" \
|
||||
--os linux --arch amd64
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/i386-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/i386-homeassistant:${tag_r}" \
|
||||
--os linux --arch 386
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/armhf-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/armhf-homeassistant:${tag_r}" \
|
||||
--os linux --arch arm --variant=v6
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/armv7-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/armv7-homeassistant:${tag_r}" \
|
||||
--os linux --arch arm --variant=v7
|
||||
|
||||
docker manifest annotate "${docker_reg}/home-assistant:${tag_l}" \
|
||||
"${docker_reg}/aarch64-homeassistant:${tag_r}" \
|
||||
docker manifest annotate "${{ matrix.registry }}/home-assistant:${tag_l}" \
|
||||
"${{ matrix.registry }}/aarch64-homeassistant:${tag_r}" \
|
||||
--os linux --arch arm64 --variant=v8
|
||||
|
||||
docker manifest push --purge "${docker_reg}/home-assistant:${tag_l}"
|
||||
docker manifest push --purge "${{ matrix.registry }}/home-assistant:${tag_l}"
|
||||
}
|
||||
|
||||
function validate_image() {
|
||||
@@ -315,36 +322,34 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
for docker_reg in "homeassistant" "ghcr.io/home-assistant"; do
|
||||
docker pull "${docker_reg}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${docker_reg}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
docker pull "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
|
||||
validate_image "${docker_reg}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${docker_reg}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/amd64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/i386-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/armhf-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/armv7-homeassistant:${{ needs.init.outputs.version }}"
|
||||
validate_image "${{ matrix.registry }}/aarch64-homeassistant:${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create version tag
|
||||
create_manifest "${docker_reg}" "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
|
||||
# Create version tag
|
||||
create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create general tags
|
||||
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
|
||||
create_manifest "${docker_reg}" "dev" "${{ needs.init.outputs.version }}"
|
||||
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
|
||||
create_manifest "${docker_reg}" "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "rc" "${{ needs.init.outputs.version }}"
|
||||
else
|
||||
create_manifest "${docker_reg}" "stable" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "latest" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "rc" "${{ needs.init.outputs.version }}"
|
||||
# Create general tags
|
||||
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
|
||||
create_manifest"dev" "${{ needs.init.outputs.version }}"
|
||||
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
|
||||
create_manifest "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "rc" "${{ needs.init.outputs.version }}"
|
||||
else
|
||||
create_manifest "stable" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "latest" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "beta" "${{ needs.init.outputs.version }}"
|
||||
create_manifest "rc" "${{ needs.init.outputs.version }}"
|
||||
|
||||
# Create series version tag (e.g. 2021.6)
|
||||
v="${{ needs.init.outputs.version }}"
|
||||
create_manifest "${docker_reg}" "${v%.*}" "${{ needs.init.outputs.version }}"
|
||||
fi
|
||||
done
|
||||
# Create series version tag (e.g. 2021.6)
|
||||
v="${{ needs.init.outputs.version }}"
|
||||
create_manifest "${v%.*}" "${{ needs.init.outputs.version }}"
|
||||
fi
|
||||
|
||||
@@ -52,6 +52,7 @@ from .const import (
|
||||
DATA_AMCREST,
|
||||
DEVICES,
|
||||
DOMAIN,
|
||||
RESOLUTION_LIST,
|
||||
SERVICE_EVENT,
|
||||
SERVICE_UPDATE,
|
||||
)
|
||||
@@ -76,8 +77,6 @@ RECHECK_INTERVAL = timedelta(minutes=1)
|
||||
NOTIFICATION_ID = "amcrest_notification"
|
||||
NOTIFICATION_TITLE = "Amcrest Camera Setup"
|
||||
|
||||
RESOLUTION_LIST = {"high": 0, "low": 1}
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
AUTHENTICATION_LIST = {"basic": "basic"}
|
||||
|
||||
@@ -35,6 +35,7 @@ from .const import (
|
||||
DATA_AMCREST,
|
||||
DEVICES,
|
||||
DOMAIN,
|
||||
RESOLUTION_TO_STREAM,
|
||||
SERVICE_UPDATE,
|
||||
SNAPSHOT_TIMEOUT,
|
||||
)
|
||||
@@ -533,13 +534,14 @@ class AmcrestCam(Camera):
|
||||
return
|
||||
|
||||
async def _async_get_video(self) -> bool:
|
||||
stream = {0: "Main", 1: "Extra"}
|
||||
return await self._api.async_is_video_enabled(
|
||||
channel=0, stream=stream[self._resolution]
|
||||
channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_set_video(self, enable: bool) -> None:
|
||||
await self._api.async_set_video_enabled(enable, channel=0)
|
||||
await self._api.async_set_video_enabled(
|
||||
enable, channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_enable_video(self, enable: bool) -> None:
|
||||
"""Enable or disable camera video stream."""
|
||||
@@ -548,7 +550,7 @@ class AmcrestCam(Camera):
|
||||
# recording on if video stream is being turned off.
|
||||
if self.is_recording and not enable:
|
||||
await self._async_enable_recording(False)
|
||||
await self._async_change_setting(enable, "video", "is_streaming")
|
||||
await self._async_change_setting(enable, "video", "_attr_is_streaming")
|
||||
if self._control_light:
|
||||
await self._async_change_light()
|
||||
|
||||
@@ -585,10 +587,14 @@ class AmcrestCam(Camera):
|
||||
)
|
||||
|
||||
async def _async_get_audio(self) -> bool:
|
||||
return await self._api.async_audio_enabled
|
||||
return await self._api.async_is_audio_enabled(
|
||||
channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_set_audio(self, enable: bool) -> None:
|
||||
await self._api.async_set_audio_enabled(enable)
|
||||
await self._api.async_set_audio_enabled(
|
||||
enable, channel=0, stream=RESOLUTION_TO_STREAM[self._resolution]
|
||||
)
|
||||
|
||||
async def _async_enable_audio(self, enable: bool) -> None:
|
||||
"""Enable or disable audio stream."""
|
||||
|
||||
@@ -13,3 +13,6 @@ SNAPSHOT_TIMEOUT = 20
|
||||
|
||||
SERVICE_EVENT = "event"
|
||||
SERVICE_UPDATE = "update"
|
||||
|
||||
RESOLUTION_LIST = {"high": 0, "low": 1}
|
||||
RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "amcrest",
|
||||
"name": "Amcrest",
|
||||
"documentation": "https://www.home-assistant.io/integrations/amcrest",
|
||||
"requirements": ["amcrest==1.9.4"],
|
||||
"requirements": ["amcrest==1.9.7"],
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@flacjacket"],
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -204,13 +204,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
# Init ip webcam
|
||||
cam = PyDroidIPCam(
|
||||
hass.loop,
|
||||
websession,
|
||||
host,
|
||||
cam_config[CONF_PORT],
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=cam_config[CONF_TIMEOUT],
|
||||
ssl=False,
|
||||
)
|
||||
|
||||
if switches is None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "android_ip_webcam",
|
||||
"name": "Android IP Webcam",
|
||||
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
|
||||
"requirements": ["pydroid-ipcam==0.8"],
|
||||
"requirements": ["pydroid-ipcam==1.3.1"],
|
||||
"codeowners": [],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -92,14 +92,15 @@ class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorE
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data["data"].get(self._description.key) is not None
|
||||
super().available and self._description.key in self.coordinator.data["data"]
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return sensor state."""
|
||||
return round(self.coordinator.data["data"][self._description.key], 2) # type: ignore[misc]
|
||||
if (value := self.coordinator.data["data"][self._description.key]) is None: # type: ignore[misc]
|
||||
return None
|
||||
return round(value, 2)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
|
||||
@@ -149,9 +149,19 @@ def _async_register_mac(
|
||||
return
|
||||
|
||||
# Make sure entity has a config entry and was disabled by the
|
||||
# default disable logic in the integration.
|
||||
# default disable logic in the integration and new entities
|
||||
# are allowed to be added.
|
||||
if (
|
||||
entity_entry.config_entry_id is None
|
||||
or (
|
||||
(
|
||||
config_entry := hass.config_entries.async_get_entry(
|
||||
entity_entry.config_entry_id
|
||||
)
|
||||
)
|
||||
is not None
|
||||
and config_entry.pref_disable_new_entities
|
||||
)
|
||||
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
|
||||
):
|
||||
return
|
||||
|
||||
@@ -20,9 +20,13 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_EMBED = "embed"
|
||||
ATTR_EMBED_AUTHOR = "author"
|
||||
ATTR_EMBED_COLOR = "color"
|
||||
ATTR_EMBED_DESCRIPTION = "description"
|
||||
ATTR_EMBED_FIELDS = "fields"
|
||||
ATTR_EMBED_FOOTER = "footer"
|
||||
ATTR_EMBED_TITLE = "title"
|
||||
ATTR_EMBED_THUMBNAIL = "thumbnail"
|
||||
ATTR_EMBED_URL = "url"
|
||||
ATTR_IMAGES = "images"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string})
|
||||
@@ -64,10 +68,16 @@ class DiscordNotificationService(BaseNotificationService):
|
||||
embeds: list[nextcord.Embed] = []
|
||||
if ATTR_EMBED in data:
|
||||
embedding = data[ATTR_EMBED]
|
||||
title = embedding.get(ATTR_EMBED_TITLE) or nextcord.Embed.Empty
|
||||
description = embedding.get(ATTR_EMBED_DESCRIPTION) or nextcord.Embed.Empty
|
||||
color = embedding.get(ATTR_EMBED_COLOR) or nextcord.Embed.Empty
|
||||
url = embedding.get(ATTR_EMBED_URL) or nextcord.Embed.Empty
|
||||
fields = embedding.get(ATTR_EMBED_FIELDS) or []
|
||||
|
||||
if embedding:
|
||||
embed = nextcord.Embed(**embedding)
|
||||
embed = nextcord.Embed(
|
||||
title=title, description=description, color=color, url=url
|
||||
)
|
||||
for field in fields:
|
||||
embed.add_field(**field)
|
||||
if ATTR_EMBED_FOOTER in embedding:
|
||||
|
||||
@@ -542,7 +542,7 @@ class DmsDeviceSource:
|
||||
children = await self._device.async_browse_direct_children(
|
||||
object_id,
|
||||
metadata_filter=DLNA_BROWSE_FILTER,
|
||||
sort_criteria=DLNA_SORT_CRITERIA,
|
||||
sort_criteria=self._sort_criteria,
|
||||
)
|
||||
|
||||
return self._didl_to_media_source(base_object, children)
|
||||
@@ -575,7 +575,8 @@ class DmsDeviceSource:
|
||||
children=children,
|
||||
)
|
||||
|
||||
media_source.calculate_children_class()
|
||||
if media_source.children:
|
||||
media_source.calculate_children_class()
|
||||
|
||||
return media_source
|
||||
|
||||
@@ -645,7 +646,8 @@ class DmsDeviceSource:
|
||||
thumbnail=self._didl_thumbnail_url(item),
|
||||
)
|
||||
|
||||
media_source.calculate_children_class()
|
||||
if media_source.children:
|
||||
media_source.calculate_children_class()
|
||||
|
||||
return media_source
|
||||
|
||||
@@ -673,6 +675,27 @@ class DmsDeviceSource:
|
||||
"""Make an identifier for BrowseMediaSource."""
|
||||
return f"{self.source_id}/{action}{object_id}"
|
||||
|
||||
@property # type: ignore
|
||||
@functools.cache
|
||||
def _sort_criteria(self) -> list[str]:
|
||||
"""Return criteria to be used for sorting results.
|
||||
|
||||
The device must be connected before reading this property.
|
||||
"""
|
||||
assert self._device
|
||||
|
||||
if self._device.sort_capabilities == ["*"]:
|
||||
return DLNA_SORT_CRITERIA
|
||||
|
||||
# Filter criteria based on what the device supports. Strings in
|
||||
# DLNA_SORT_CRITERIA are prefixed with a sign, while those in
|
||||
# the device's sort_capabilities are not.
|
||||
return [
|
||||
criterion
|
||||
for criterion in DLNA_SORT_CRITERIA
|
||||
if criterion[1:] in self._device.sort_capabilities
|
||||
]
|
||||
|
||||
|
||||
class Action(StrEnum):
|
||||
"""Actions that can be specified in a DMS media-source identifier."""
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components import zeroconf
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.util.network import is_link_local
|
||||
from homeassistant.util.network import is_ipv4_address, is_link_local
|
||||
|
||||
from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI
|
||||
from .util import get_mac_address_from_doorstation_info
|
||||
@@ -103,6 +103,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="not_doorbird_device")
|
||||
if is_link_local(ip_address(host)):
|
||||
return self.async_abort(reason="link_local_address")
|
||||
if not is_ipv4_address(host):
|
||||
return self.async_abort(reason="not_ipv4_address")
|
||||
|
||||
await self.async_set_unique_id(macaddress)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"link_local_address": "Link local addresses are not supported",
|
||||
"not_doorbird_device": "This device is not a DoorBird"
|
||||
"not_doorbird_device": "This device is not a DoorBird",
|
||||
"not_ipv4_address": "Only IPv4 addresess are supported"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Support for Elgato Lights."""
|
||||
from typing import NamedTuple
|
||||
|
||||
from elgato import Elgato, Info, State
|
||||
from elgato import Elgato, ElgatoConnectionError, Info, State
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
|
||||
|
||||
@@ -31,12 +31,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
session=session,
|
||||
)
|
||||
|
||||
async def _async_update_data() -> State:
|
||||
"""Fetch Elgato data."""
|
||||
try:
|
||||
return await elgato.state()
|
||||
except ElgatoConnectionError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
update_method=elgato.state,
|
||||
update_method=_async_update_data,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -279,9 +279,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
keypad.add_callback(_element_changed)
|
||||
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(
|
||||
elk, LOGIN_TIMEOUT, SYNC_TIMEOUT, bool(conf[CONF_USERNAME])
|
||||
):
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT):
|
||||
return False
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc
|
||||
@@ -334,7 +332,6 @@ async def async_wait_for_elk_to_sync(
|
||||
elk: elkm1.Elk,
|
||||
login_timeout: int,
|
||||
sync_timeout: int,
|
||||
password_auth: bool,
|
||||
) -> bool:
|
||||
"""Wait until the elk has finished sync. Can fail login or timeout."""
|
||||
|
||||
@@ -354,18 +351,23 @@ async def async_wait_for_elk_to_sync(
|
||||
login_event.set()
|
||||
sync_event.set()
|
||||
|
||||
def first_response(*args, **kwargs):
|
||||
_LOGGER.debug("ElkM1 received first response (VN)")
|
||||
login_event.set()
|
||||
|
||||
def sync_complete():
|
||||
sync_event.set()
|
||||
|
||||
success = True
|
||||
elk.add_handler("login", login_status)
|
||||
# VN is the first command sent for panel, when we get
|
||||
# it back we now we are logged in either with or without a password
|
||||
elk.add_handler("VN", first_response)
|
||||
elk.add_handler("sync_complete", sync_complete)
|
||||
events = []
|
||||
if password_auth:
|
||||
events.append(("login", login_event, login_timeout))
|
||||
events.append(("sync_complete", sync_event, sync_timeout))
|
||||
|
||||
for name, event, timeout in events:
|
||||
for name, event, timeout in (
|
||||
("login", login_event, login_timeout),
|
||||
("sync_complete", sync_event, sync_timeout),
|
||||
):
|
||||
_LOGGER.debug("Waiting for %s event for %s seconds", name, timeout)
|
||||
try:
|
||||
async with async_timeout.timeout(timeout):
|
||||
|
||||
@@ -81,10 +81,11 @@ async def validate_input(data: dict[str, str], mac: str | None) -> dict[str, str
|
||||
)
|
||||
elk.connect()
|
||||
|
||||
if not await async_wait_for_elk_to_sync(
|
||||
elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT, bool(userid)
|
||||
):
|
||||
raise InvalidAuth
|
||||
try:
|
||||
if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, VALIDATE_TIMEOUT):
|
||||
raise InvalidAuth
|
||||
finally:
|
||||
elk.disconnect()
|
||||
|
||||
short_mac = _short_mac(mac) if mac else None
|
||||
if prefix and prefix != short_mac:
|
||||
@@ -227,7 +228,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
info = await validate_input(user_input, self.unique_id)
|
||||
except asyncio.TimeoutError:
|
||||
return {CONF_HOST: "cannot_connect"}, None
|
||||
return {"base": "cannot_connect"}, None
|
||||
except InvalidAuth:
|
||||
return {CONF_PASSWORD: "invalid_auth"}, None
|
||||
except Exception: # pylint: disable=broad-except
|
||||
@@ -287,9 +288,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if device := await async_discover_device(
|
||||
self.hass, user_input[CONF_ADDRESS]
|
||||
):
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
await self.async_set_unique_id(
|
||||
dr.format_mac(device.mac_address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
user_input[CONF_ADDRESS] = f"{device.ip_address}:{device.port}"
|
||||
# Ignore the port from discovery since its always going to be
|
||||
# 2601 if secure is turned on even though they may want insecure
|
||||
user_input[CONF_ADDRESS] = device.ip_address
|
||||
errors, result = await self._async_create_or_error(user_input, False)
|
||||
if not errors:
|
||||
return result
|
||||
@@ -324,7 +329,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if is_ip_address(host) and (
|
||||
device := await async_discover_device(self.hass, host)
|
||||
):
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
await self.async_set_unique_id(
|
||||
dr.format_mac(device.mac_address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return (await self._async_create_or_error(user_input, True))[1]
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.util.network import is_ipv4_address
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -86,6 +87,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
if not is_ipv4_address(discovery_info.host):
|
||||
return self.async_abort(reason="not_ipv4_address")
|
||||
serial = discovery_info.properties["serialnum"]
|
||||
await self.async_set_unique_id(serial)
|
||||
self.ip_address = discovery_info.host
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"not_ipv4_address": "Only IPv4 addresess are supported"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from flipr_api import FliprAPIRestClient
|
||||
from flipr_api.exceptions import FliprError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
@@ -11,6 +12,7 @@ from homeassistant.helpers.entity import DeviceInfo, EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME
|
||||
@@ -68,9 +70,14 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Fetch data from API endpoint."""
|
||||
return await self.hass.async_add_executor_job(
|
||||
self.client.get_pool_measure_latest, self.flipr_id
|
||||
)
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(
|
||||
self.client.get_pool_measure_latest, self.flipr_id
|
||||
)
|
||||
except (FliprError) as error:
|
||||
raise UpdateFailed(error) from error
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class FliprEntity(CoordinatorEntity):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/flipr",
|
||||
"requirements": [
|
||||
"flipr-api==1.4.1"],
|
||||
"flipr-api==1.4.2"],
|
||||
"codeowners": [
|
||||
"@cnico"
|
||||
],
|
||||
|
||||
@@ -64,7 +64,7 @@ async def async_setup_entry(
|
||||
coordinator, base_unique_id, f"{name} Operating Mode", "operating_mode"
|
||||
)
|
||||
)
|
||||
if device.wirings:
|
||||
if device.wirings and device.wiring is not None:
|
||||
entities.append(
|
||||
FluxWiringsSelect(coordinator, base_unique_id, f"{name} Wiring", "wiring")
|
||||
)
|
||||
|
||||
@@ -33,6 +33,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in avm_wrapper.connection.services
|
||||
and not (await avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
raise ConfigEntryAuthFailed("Missing UPnP configuration")
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = avm_wrapper
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""AVM FRITZ!Box connectivity sensor."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
@@ -14,8 +15,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .common import AvmWrapper, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, MeshRoles
|
||||
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class FritzBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Fritz sensor entity."""
|
||||
|
||||
exclude_mesh_role: MeshRoles = MeshRoles.SLAVE
|
||||
is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled
|
||||
|
||||
|
||||
SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
|
||||
@@ -45,7 +46,7 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
|
||||
name="Firmware Update",
|
||||
device_class=BinarySensorDeviceClass.UPDATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
exclude_mesh_role=MeshRoles.NONE,
|
||||
is_suitable=lambda info: True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,10 +58,12 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Setting up FRITZ!Box binary sensors")
|
||||
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
connection_info = await avm_wrapper.async_get_connection_info()
|
||||
|
||||
entities = [
|
||||
FritzBoxBinarySensor(avm_wrapper, entry.title, description)
|
||||
for description in SENSOR_TYPES
|
||||
if (description.exclude_mesh_role != avm_wrapper.mesh_role)
|
||||
if description.is_suitable(connection_info)
|
||||
]
|
||||
|
||||
async_add_entities(entities, True)
|
||||
|
||||
@@ -392,6 +392,8 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator):
|
||||
)
|
||||
self.mesh_role = MeshRoles.NONE
|
||||
for mac, info in hosts.items():
|
||||
if info.ip_address:
|
||||
info.wan_access = self._get_wan_access(info.ip_address)
|
||||
if self.manage_device_info(info, mac, consider_home):
|
||||
new_device = True
|
||||
self.send_signal_device_update(new_device)
|
||||
@@ -630,6 +632,11 @@ class AvmWrapper(FritzBoxTools):
|
||||
)
|
||||
return {}
|
||||
|
||||
async def async_get_upnp_configuration(self) -> dict[str, Any]:
|
||||
"""Call X_AVM-DE_UPnP service."""
|
||||
|
||||
return await self.hass.async_add_executor_job(self.get_upnp_configuration)
|
||||
|
||||
async def async_get_wan_link_properties(self) -> dict[str, Any]:
|
||||
"""Call WANCommonInterfaceConfig service."""
|
||||
|
||||
@@ -637,6 +644,22 @@ class AvmWrapper(FritzBoxTools):
|
||||
partial(self.get_wan_link_properties)
|
||||
)
|
||||
|
||||
async def async_get_connection_info(self) -> ConnectionInfo:
|
||||
"""Return ConnectionInfo data."""
|
||||
|
||||
link_properties = await self.async_get_wan_link_properties()
|
||||
connection_info = ConnectionInfo(
|
||||
connection=link_properties.get("NewWANAccessType", "").lower(),
|
||||
mesh_role=self.mesh_role,
|
||||
wan_enabled=self.device_is_router,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"ConnectionInfo for FritzBox %s: %s",
|
||||
self.host,
|
||||
connection_info,
|
||||
)
|
||||
return connection_info
|
||||
|
||||
async def async_get_port_mapping(self, con_type: str, index: int) -> dict[str, Any]:
|
||||
"""Call GetGenericPortMappingEntry action."""
|
||||
|
||||
@@ -698,6 +721,11 @@ class AvmWrapper(FritzBoxTools):
|
||||
partial(self.set_allow_wan_access, ip_address, turn_on)
|
||||
)
|
||||
|
||||
def get_upnp_configuration(self) -> dict[str, Any]:
|
||||
"""Call X_AVM-DE_UPnP service."""
|
||||
|
||||
return self._service_call_action("X_AVM-DE_UPnP", "1", "GetInfo")
|
||||
|
||||
def get_ontel_num_deflections(self) -> dict[str, Any]:
|
||||
"""Call GetNumberOfDeflections action from X_AVM-DE_OnTel service."""
|
||||
|
||||
@@ -960,3 +988,12 @@ class FritzBoxBaseEntity:
|
||||
name=self._device_name,
|
||||
sw_version=self._avm_wrapper.current_firmware,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
|
||||
@@ -29,6 +29,7 @@ from .const import (
|
||||
ERROR_AUTH_INVALID,
|
||||
ERROR_CANNOT_CONNECT,
|
||||
ERROR_UNKNOWN,
|
||||
ERROR_UPNP_NOT_CONFIGURED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -79,6 +80,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return ERROR_UNKNOWN
|
||||
|
||||
if (
|
||||
"X_AVM-DE_UPnP1" in self.avm_wrapper.connection.services
|
||||
and not (await self.avm_wrapper.async_get_upnp_configuration())["NewEnable"]
|
||||
):
|
||||
return ERROR_UPNP_NOT_CONFIGURED
|
||||
|
||||
return None
|
||||
|
||||
async def async_check_configured_entry(self) -> ConfigEntry | None:
|
||||
|
||||
@@ -46,6 +46,7 @@ DEFAULT_USERNAME = ""
|
||||
|
||||
ERROR_AUTH_INVALID = "invalid_auth"
|
||||
ERROR_CANNOT_CONNECT = "cannot_connect"
|
||||
ERROR_UPNP_NOT_CONFIGURED = "upnp_not_configured"
|
||||
ERROR_UNKNOWN = "unknown_error"
|
||||
|
||||
FRITZ_SERVICES = "fritz_services"
|
||||
@@ -56,6 +57,7 @@ SERVICE_SET_GUEST_WIFI_PW = "set_guest_wifi_password"
|
||||
|
||||
SWITCH_TYPE_DEFLECTION = "CallDeflection"
|
||||
SWITCH_TYPE_PORTFORWARD = "PortForward"
|
||||
SWITCH_TYPE_PROFILE = "Profile"
|
||||
SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
|
||||
|
||||
UPTIME_DEVIATION = 5
|
||||
|
||||
@@ -22,6 +22,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"device_info": {
|
||||
"model": avm_wrapper.model,
|
||||
"unique_id": avm_wrapper.unique_id.replace(
|
||||
avm_wrapper.unique_id[6:11], "XX:XX"
|
||||
),
|
||||
"current_firmware": avm_wrapper.current_firmware,
|
||||
"latest_firmware": avm_wrapper.latest_firmware,
|
||||
"update_available": avm_wrapper.update_available,
|
||||
|
||||
@@ -28,8 +28,8 @@ from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .common import AvmWrapper, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION, MeshRoles
|
||||
from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity
|
||||
from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,15 +134,6 @@ def _retrieve_link_attenuation_received_state(
|
||||
return status.attenuation[1] / 10 # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class FritzRequireKeysMixin:
|
||||
"""Fritz sensor data class."""
|
||||
@@ -283,18 +274,7 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Setting up FRITZ!Box sensors")
|
||||
avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
link_properties = await avm_wrapper.async_get_wan_link_properties()
|
||||
connection_info = ConnectionInfo(
|
||||
connection=link_properties.get("NewWANAccessType", "").lower(),
|
||||
mesh_role=avm_wrapper.mesh_role,
|
||||
wan_enabled=avm_wrapper.device_is_router,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"ConnectionInfo for FritzBox %s: %s",
|
||||
avm_wrapper.host,
|
||||
connection_info,
|
||||
)
|
||||
connection_info = await avm_wrapper.async_get_connection_info()
|
||||
|
||||
entities = [
|
||||
FritzBoxSensor(avm_wrapper, entry.title, description)
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"upnp_not_configured": "Missing UPnP settings on device.",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
|
||||
@@ -30,6 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
SWITCH_TYPE_DEFLECTION,
|
||||
SWITCH_TYPE_PORTFORWARD,
|
||||
SWITCH_TYPE_PROFILE,
|
||||
SWITCH_TYPE_WIFINETWORK,
|
||||
WIFI_STANDARD,
|
||||
MeshRoles,
|
||||
@@ -185,6 +186,7 @@ def profile_entities_list(
|
||||
data_fritz: FritzData,
|
||||
) -> list[FritzBoxProfileSwitch]:
|
||||
"""Add new tracker entities from the AVM device."""
|
||||
_LOGGER.debug("Setting up %s switches", SWITCH_TYPE_PROFILE)
|
||||
|
||||
new_profiles: list[FritzBoxProfileSwitch] = []
|
||||
|
||||
@@ -198,11 +200,15 @@ def profile_entities_list(
|
||||
if device_filter_out_from_trackers(
|
||||
mac, device, data_fritz.profile_switches.values()
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Skipping profile switch creation for device %s", device.hostname
|
||||
)
|
||||
continue
|
||||
|
||||
new_profiles.append(FritzBoxProfileSwitch(avm_wrapper, device))
|
||||
data_fritz.profile_switches[avm_wrapper.unique_id].add(mac)
|
||||
|
||||
_LOGGER.debug("Creating %s profile switches", len(new_profiles))
|
||||
return new_profiles
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"already_configured": "Device is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication"
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"upnp_not_configured": "Missing UPnP settings on device."
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
@@ -51,4 +52,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": [
|
||||
"home-assistant-frontend==20220226.0"
|
||||
"home-assistant-frontend==20220301.2"
|
||||
],
|
||||
"dependencies": [
|
||||
"api",
|
||||
@@ -13,7 +13,8 @@
|
||||
"diagnostics",
|
||||
"http",
|
||||
"lovelace",
|
||||
"onboarding", "search",
|
||||
"onboarding",
|
||||
"search",
|
||||
"system_log",
|
||||
"websocket_api"
|
||||
],
|
||||
|
||||
@@ -59,11 +59,12 @@ SERVICE_SET = "set"
|
||||
SERVICE_REMOVE = "remove"
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.COVER,
|
||||
Platform.NOTIFY,
|
||||
Platform.FAN,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.COVER,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NOTIFY,
|
||||
]
|
||||
|
||||
REG_KEY = f"{DOMAIN}_registry"
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
CONF_UNIQUE_ID,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
@@ -80,7 +81,6 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
|
||||
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
|
||||
self._attr_unique_id = unique_id
|
||||
self._device_class = device_class
|
||||
self._state: str | None = None
|
||||
self.mode = any
|
||||
if mode:
|
||||
self.mode = all
|
||||
@@ -106,13 +106,23 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity):
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the binary sensor group state."""
|
||||
all_states = [self.hass.states.get(x) for x in self._entity_ids]
|
||||
|
||||
# filtered_states are members currently in the state machine
|
||||
filtered_states: list[str] = [x.state for x in all_states if x is not None]
|
||||
|
||||
# Set group as unavailable if all members are unavailable
|
||||
self._attr_available = any(
|
||||
state != STATE_UNAVAILABLE for state in filtered_states
|
||||
)
|
||||
if STATE_UNAVAILABLE in filtered_states:
|
||||
|
||||
valid_state = self.mode(
|
||||
state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states
|
||||
)
|
||||
if not valid_state:
|
||||
# Set as unknown if any / all member is not unknown or unavailable
|
||||
self._attr_is_on = None
|
||||
else:
|
||||
# Set as ON if any / all member is ON
|
||||
states = list(map(lambda x: x == STATE_ON, filtered_states))
|
||||
state = self.mode(states)
|
||||
self._attr_is_on = state
|
||||
|
||||
@@ -221,12 +221,9 @@ class GrowattData:
|
||||
# Create datetime from the latest entry
|
||||
date_now = dt.now().date()
|
||||
last_updated_time = dt.parse_time(str(sorted_keys[-1]))
|
||||
combined_timestamp = datetime.datetime.combine(
|
||||
date_now, last_updated_time
|
||||
mix_detail["lastdataupdate"] = datetime.datetime.combine(
|
||||
date_now, last_updated_time, dt.DEFAULT_TIME_ZONE
|
||||
)
|
||||
# Convert datetime to UTC
|
||||
combined_timestamp_utc = dt.as_utc(combined_timestamp)
|
||||
mix_detail["lastdataupdate"] = combined_timestamp_utc.isoformat()
|
||||
|
||||
# Dashboard data is largely inaccurate for mix system but it is the only call with the ability to return the combined
|
||||
# imported from grid value that is the combination of charging AND load consumption
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"dependencies": ["http"],
|
||||
"codeowners": ["@DavidMStraub"],
|
||||
"requirements": ["homeconnect==0.6.3"],
|
||||
"requirements": ["homeconnect==0.7.0"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homeconnect"]
|
||||
|
||||
@@ -274,7 +274,7 @@ class HomeAccessory(Accessory):
|
||||
if self.config.get(ATTR_SW_VERSION) is not None:
|
||||
sw_version = format_version(self.config[ATTR_SW_VERSION])
|
||||
if sw_version is None:
|
||||
sw_version = __version__
|
||||
sw_version = format_version(__version__)
|
||||
hw_version = None
|
||||
if self.config.get(ATTR_HW_VERSION) is not None:
|
||||
hw_version = format_version(self.config[ATTR_HW_VERSION])
|
||||
@@ -289,7 +289,9 @@ class HomeAccessory(Accessory):
|
||||
serv_info = self.get_service(SERV_ACCESSORY_INFO)
|
||||
char = self.driver.loader.get_char(CHAR_HARDWARE_REVISION)
|
||||
serv_info.add_characteristic(char)
|
||||
serv_info.configure_char(CHAR_HARDWARE_REVISION, value=hw_version)
|
||||
serv_info.configure_char(
|
||||
CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH]
|
||||
)
|
||||
self.iid_manager.assign(char)
|
||||
char.broker = self
|
||||
|
||||
@@ -532,7 +534,7 @@ class HomeBridge(Bridge):
|
||||
"""Initialize a Bridge object."""
|
||||
super().__init__(driver, name)
|
||||
self.set_info_service(
|
||||
firmware_revision=__version__,
|
||||
firmware_revision=format_version(__version__),
|
||||
manufacturer=MANUFACTURER,
|
||||
model=BRIDGE_MODEL,
|
||||
serial_number=BRIDGE_SERIAL_NUMBER,
|
||||
|
||||
@@ -285,20 +285,19 @@ class Thermostat(HomeAccessory):
|
||||
CHAR_CURRENT_HUMIDITY, value=50
|
||||
)
|
||||
|
||||
fan_modes = self.fan_modes = {
|
||||
fan_mode.lower(): fan_mode
|
||||
for fan_mode in attributes.get(ATTR_FAN_MODES, [])
|
||||
}
|
||||
fan_modes = {}
|
||||
self.ordered_fan_speeds = []
|
||||
if (
|
||||
features & SUPPORT_FAN_MODE
|
||||
and fan_modes
|
||||
and PRE_DEFINED_FAN_MODES.intersection(fan_modes)
|
||||
):
|
||||
self.ordered_fan_speeds = [
|
||||
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
|
||||
]
|
||||
self.fan_chars.append(CHAR_ROTATION_SPEED)
|
||||
|
||||
if features & SUPPORT_FAN_MODE:
|
||||
fan_modes = {
|
||||
fan_mode.lower(): fan_mode
|
||||
for fan_mode in attributes.get(ATTR_FAN_MODES) or []
|
||||
}
|
||||
if fan_modes and PRE_DEFINED_FAN_MODES.intersection(fan_modes):
|
||||
self.ordered_fan_speeds = [
|
||||
speed for speed in ORDERED_FAN_SPEEDS if speed in fan_modes
|
||||
]
|
||||
self.fan_chars.append(CHAR_ROTATION_SPEED)
|
||||
|
||||
if FAN_AUTO in fan_modes and (FAN_ON in fan_modes or self.ordered_fan_speeds):
|
||||
self.fan_chars.append(CHAR_TARGET_FAN_STATE)
|
||||
|
||||
@@ -100,6 +100,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
NUMBERS_ONLY_RE = re.compile(r"[^\d.]+")
|
||||
VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?")
|
||||
MAX_VERSION_PART = 2**32 - 1
|
||||
|
||||
|
||||
MAX_PORT = 65535
|
||||
@@ -363,7 +364,15 @@ def convert_to_float(state):
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_name_for_homekit(name: str | None) -> str | None:
|
||||
def coerce_int(state: str) -> int:
|
||||
"""Return int."""
|
||||
try:
|
||||
return int(state)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_name_for_homekit(name: str | None) -> str:
|
||||
"""Ensure the name of the device will not crash homekit."""
|
||||
#
|
||||
# This is not a security measure.
|
||||
@@ -371,7 +380,7 @@ def cleanup_name_for_homekit(name: str | None) -> str | None:
|
||||
# UNICODE_EMOJI is also not allowed but that
|
||||
# likely isn't a problem
|
||||
if name is None:
|
||||
return None
|
||||
return "None" # None crashes apple watches
|
||||
return name.translate(HOMEKIT_CHAR_TRANSLATIONS)[:MAX_NAME_LENGTH]
|
||||
|
||||
|
||||
@@ -420,13 +429,23 @@ def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _format_version_part(version_part: str) -> str:
|
||||
return str(max(0, min(MAX_VERSION_PART, coerce_int(version_part))))
|
||||
|
||||
|
||||
def format_version(version):
|
||||
"""Extract the version string in a format homekit can consume."""
|
||||
split_ver = str(version).replace("-", ".")
|
||||
split_ver = str(version).replace("-", ".").replace(" ", ".")
|
||||
num_only = NUMBERS_ONLY_RE.sub("", split_ver)
|
||||
if match := VERSION_RE.search(num_only):
|
||||
return match.group(0)
|
||||
return None
|
||||
if (match := VERSION_RE.search(num_only)) is None:
|
||||
return None
|
||||
value = ".".join(map(_format_version_part, match.group(0).split(".")))
|
||||
return None if _is_zero_but_true(value) else value
|
||||
|
||||
|
||||
def _is_zero_but_true(value):
|
||||
"""Zero but true values can crash apple watches."""
|
||||
return convert_to_float(value) == 0
|
||||
|
||||
|
||||
def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomeKit Controller",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"requirements": ["aiohomekit==0.7.14"],
|
||||
"requirements": ["aiohomekit==0.7.16"],
|
||||
"zeroconf": ["_hap._tcp.local."],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
"codeowners": ["@Jc2k", "@bdraco"],
|
||||
|
||||
@@ -48,42 +48,6 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = {
|
||||
icon="mdi:volume-high",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_COOL: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_COOL,
|
||||
name="Home Cool Target",
|
||||
icon="mdi:thermometer-minus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_HEAT: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_HOME_TARGET_HEAT,
|
||||
name="Home Heat Target",
|
||||
icon="mdi:thermometer-plus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_COOL: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_COOL,
|
||||
name="Sleep Cool Target",
|
||||
icon="mdi:thermometer-minus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_HEAT: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_SLEEP_TARGET_HEAT,
|
||||
name="Sleep Heat Target",
|
||||
icon="mdi:thermometer-plus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_COOL: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_COOL,
|
||||
name="Away Cool Target",
|
||||
icon="mdi:thermometer-minus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_HEAT: NumberEntityDescription(
|
||||
key=CharacteristicsTypes.VENDOR_ECOBEE_AWAY_TARGET_HEAT,
|
||||
name="Away Heat Target",
|
||||
icon="mdi:thermometer-plus",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from aiohue import LinkButtonNotPressed, create_app_key
|
||||
from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp
|
||||
from aiohue.util import normalize_bridge_id
|
||||
@@ -70,9 +71,12 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self, host: str, bridge_id: str | None = None
|
||||
) -> DiscoveredHueBridge:
|
||||
"""Return a DiscoveredHueBridge object."""
|
||||
bridge = await discover_bridge(
|
||||
host, websession=aiohttp_client.async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
bridge = await discover_bridge(
|
||||
host, websession=aiohttp_client.async_get_clientsession(self.hass)
|
||||
)
|
||||
except aiohttp.ClientError:
|
||||
return None
|
||||
if bridge_id is not None:
|
||||
bridge_id = normalize_bridge_id(bridge_id)
|
||||
assert bridge_id == bridge.id
|
||||
|
||||
@@ -42,6 +42,14 @@ ALLOWED_ERRORS = [
|
||||
'device (groupedLight) is "soft off", command (on) may not have effect',
|
||||
"device (light) has communication issues, command (on) may not have effect",
|
||||
'device (light) is "soft off", command (on) may not have effect',
|
||||
"device (grouped_light) has communication issues, command (.on) may not have effect",
|
||||
'device (grouped_light) is "soft off", command (.on) may not have effect'
|
||||
"device (grouped_light) has communication issues, command (.on.on) may not have effect",
|
||||
'device (grouped_light) is "soft off", command (.on.on) may not have effect'
|
||||
"device (light) has communication issues, command (.on) may not have effect",
|
||||
'device (light) is "soft off", command (.on) may not have effect',
|
||||
"device (light) has communication issues, command (.on.on) may not have effect",
|
||||
'device (light) is "soft off", command (.on.on) may not have effect',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ from .helpers import (
|
||||
ALLOWED_ERRORS = [
|
||||
"device (light) has communication issues, command (on) may not have effect",
|
||||
'device (light) is "soft off", command (on) may not have effect',
|
||||
"device (light) has communication issues, command (.on) may not have effect",
|
||||
'device (light) is "soft off", command (.on) may not have effect',
|
||||
"device (light) has communication issues, command (.on.on) may not have effect",
|
||||
'device (light) is "soft off", command (.on.on) may not have effect',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Insteon",
|
||||
"documentation": "https://www.home-assistant.io/integrations/insteon",
|
||||
"requirements": [
|
||||
"pyinsteon==1.0.16"
|
||||
"pyinsteon==1.0.13"
|
||||
],
|
||||
"codeowners": [
|
||||
"@teharris1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "isy994",
|
||||
"name": "Universal Devices ISY994",
|
||||
"documentation": "https://www.home-assistant.io/integrations/isy994",
|
||||
"requirements": ["pyisy==3.0.1"],
|
||||
"requirements": ["pyisy==3.0.5"],
|
||||
"codeowners": ["@bdraco", "@shbatm"],
|
||||
"config_flow": true,
|
||||
"ssdp": [
|
||||
|
||||
@@ -717,6 +717,8 @@ class KodiEntity(MediaPlayerEntity):
|
||||
await self._kodi.play_channel(int(media_id))
|
||||
elif media_type_lower == MEDIA_TYPE_PLAYLIST:
|
||||
await self._kodi.play_playlist(int(media_id))
|
||||
elif media_type_lower == "file":
|
||||
await self._kodi.play_file(media_id)
|
||||
elif media_type_lower == "directory":
|
||||
await self._kodi.play_directory(media_id)
|
||||
elif media_type_lower in [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "LIFX",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lifx",
|
||||
"requirements": ["aiolifx==0.7.0", "aiolifx_effects==0.2.2"],
|
||||
"requirements": ["aiolifx==0.7.1", "aiolifx_effects==0.2.2"],
|
||||
"homekit": {
|
||||
"models": ["LIFX"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Support for Honeywell Lyric climate platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from time import localtime, strftime, time
|
||||
|
||||
@@ -22,6 +23,7 @@ from homeassistant.components.climate.const import (
|
||||
HVAC_MODE_OFF,
|
||||
SUPPORT_PRESET_MODE,
|
||||
SUPPORT_TARGET_TEMPERATURE,
|
||||
SUPPORT_TARGET_TEMPERATURE_RANGE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
@@ -45,7 +47,11 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE
|
||||
# Only LCC models support presets
|
||||
SUPPORT_FLAGS_LCC = (
|
||||
SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
)
|
||||
SUPPORT_FLAGS_TCC = SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE
|
||||
|
||||
LYRIC_HVAC_ACTION_OFF = "EquipmentOff"
|
||||
LYRIC_HVAC_ACTION_HEAT = "Heat"
|
||||
@@ -166,7 +172,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_FLAGS
|
||||
if self.device.changeableValues.thermostatSetpointStatus:
|
||||
support_flags = SUPPORT_FLAGS_LCC
|
||||
else:
|
||||
support_flags = SUPPORT_FLAGS_TCC
|
||||
return support_flags
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
@@ -200,25 +210,28 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
device = self.device
|
||||
if not device.hasDualSetpointStatus:
|
||||
if (
|
||||
not device.changeableValues.autoChangeoverActive
|
||||
and HVAC_MODES[device.changeableValues.mode] != HVAC_MODE_OFF
|
||||
):
|
||||
if self.hvac_mode == HVAC_MODE_COOL:
|
||||
return device.changeableValues.coolSetpoint
|
||||
return device.changeableValues.heatSetpoint
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the upper bound temperature we try to reach."""
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the highbound target temperature we try to reach."""
|
||||
device = self.device
|
||||
if device.hasDualSetpointStatus:
|
||||
if device.changeableValues.autoChangeoverActive:
|
||||
return device.changeableValues.coolSetpoint
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the upper bound temperature we try to reach."""
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the lowbound target temperature we try to reach."""
|
||||
device = self.device
|
||||
if device.hasDualSetpointStatus:
|
||||
if device.changeableValues.autoChangeoverActive:
|
||||
return device.changeableValues.heatSetpoint
|
||||
return None
|
||||
|
||||
@@ -256,11 +269,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
"""Set new target temperature."""
|
||||
device = self.device
|
||||
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
|
||||
device = self.device
|
||||
if device.hasDualSetpointStatus:
|
||||
if device.changeableValues.autoChangeoverActive:
|
||||
if target_temp_low is None or target_temp_high is None:
|
||||
raise HomeAssistantError(
|
||||
"Could not find target_temp_low and/or target_temp_high in arguments"
|
||||
@@ -270,11 +283,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
device,
|
||||
coolSetpoint=target_temp_low,
|
||||
heatSetpoint=target_temp_high,
|
||||
coolSetpoint=target_temp_high,
|
||||
heatSetpoint=target_temp_low,
|
||||
mode=HVAC_MODES[device.changeableValues.heatCoolMode],
|
||||
)
|
||||
except LYRIC_EXCEPTIONS as exception:
|
||||
_LOGGER.error(exception)
|
||||
await self.coordinator.async_refresh()
|
||||
else:
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
_LOGGER.debug("Set temperature: %s", temp)
|
||||
@@ -289,15 +304,58 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
|
||||
)
|
||||
except LYRIC_EXCEPTIONS as exception:
|
||||
_LOGGER.error(exception)
|
||||
await self.coordinator.async_refresh()
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
|
||||
"""Set hvac mode."""
|
||||
_LOGGER.debug("Set hvac mode: %s", hvac_mode)
|
||||
_LOGGER.debug("HVAC mode: %s", hvac_mode)
|
||||
try:
|
||||
await self._update_thermostat(
|
||||
self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode]
|
||||
)
|
||||
if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL:
|
||||
# If the system is off, turn it to Heat first then to Auto, otherwise it turns to
|
||||
# Auto briefly and then reverts to Off (perhaps related to heatCoolMode). This is the
|
||||
# behavior that happens with the native app as well, so likely a bug in the api itself
|
||||
|
||||
if HVAC_MODES[self.device.changeableValues.mode] == HVAC_MODE_OFF:
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s",
|
||||
HVAC_MODES[LYRIC_HVAC_MODE_COOL],
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
self.device,
|
||||
mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
|
||||
autoChangeoverActive=False,
|
||||
)
|
||||
# Sleep 3 seconds before proceeding
|
||||
await asyncio.sleep(3)
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s",
|
||||
HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
self.device,
|
||||
mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT],
|
||||
autoChangeoverActive=True,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s",
|
||||
HVAC_MODES[self.device.changeableValues.mode],
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location, self.device, autoChangeoverActive=True
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]
|
||||
)
|
||||
await self._update_thermostat(
|
||||
self.location,
|
||||
self.device,
|
||||
mode=LYRIC_HVAC_MODES[hvac_mode],
|
||||
autoChangeoverActive=False,
|
||||
)
|
||||
except LYRIC_EXCEPTIONS as exception:
|
||||
_LOGGER.error(exception)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@@ -243,7 +243,10 @@ class MatrixBot:
|
||||
room.update_aliases()
|
||||
self._aliases_fetched_for.add(room.room_id)
|
||||
|
||||
if room_id_or_alias in room.aliases:
|
||||
if (
|
||||
room_id_or_alias in room.aliases
|
||||
or room_id_or_alias == room.canonical_alias
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Already in room %s (known as %s)", room.room_id, room_id_or_alias
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import yarl
|
||||
@@ -74,11 +75,15 @@ class BrowseMedia:
|
||||
|
||||
def as_dict(self, *, parent: bool = True) -> dict:
|
||||
"""Convert Media class to browse media dictionary."""
|
||||
response = {
|
||||
if self.children_media_class is None and self.children:
|
||||
self.calculate_children_class()
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"title": self.title,
|
||||
"media_class": self.media_class,
|
||||
"media_content_type": self.media_content_type,
|
||||
"media_content_id": self.media_content_id,
|
||||
"children_media_class": self.children_media_class,
|
||||
"can_play": self.can_play,
|
||||
"can_expand": self.can_expand,
|
||||
"thumbnail": self.thumbnail,
|
||||
@@ -87,11 +92,7 @@ class BrowseMedia:
|
||||
if not parent:
|
||||
return response
|
||||
|
||||
if self.children_media_class is None:
|
||||
self.calculate_children_class()
|
||||
|
||||
response["not_shown"] = self.not_shown
|
||||
response["children_media_class"] = self.children_media_class
|
||||
|
||||
if self.children:
|
||||
response["children"] = [
|
||||
@@ -104,11 +105,8 @@ class BrowseMedia:
|
||||
|
||||
def calculate_children_class(self) -> None:
|
||||
"""Count the children media classes and calculate the correct class."""
|
||||
if self.children is None or len(self.children) == 0:
|
||||
return
|
||||
|
||||
self.children_media_class = MEDIA_CLASS_DIRECTORY
|
||||
|
||||
assert self.children is not None
|
||||
proposed_class = self.children[0].media_class
|
||||
if all(child.media_class == proposed_class for child in self.children):
|
||||
self.children_media_class = proposed_class
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "mediaroom",
|
||||
"name": "Mediaroom",
|
||||
"documentation": "https://www.home-assistant.io/integrations/mediaroom",
|
||||
"requirements": ["pymediaroom==0.6.4.1"],
|
||||
"requirements": ["pymediaroom==0.6.5.4"],
|
||||
"codeowners": ["@dgomes"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pymediaroom"]
|
||||
|
||||
@@ -28,6 +28,7 @@ ATTR_CONFIG_ENTRY_ID = "entry_id"
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
ATTR_MANUFACTURER = "manufacturer"
|
||||
ATTR_MODEL = "model"
|
||||
ATTR_NO_LEGACY_ENCRYPTION = "no_legacy_encryption"
|
||||
ATTR_OS_NAME = "os_name"
|
||||
ATTR_OS_VERSION = "os_version"
|
||||
ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel"
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import logging
|
||||
|
||||
from aiohttp.web import Response, json_response
|
||||
from nacl.encoding import Base64Encoder
|
||||
from nacl.encoding import Base64Encoder, HexEncoder, RawEncoder
|
||||
from nacl.secret import SecretBox
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON
|
||||
@@ -23,6 +23,7 @@ from .const import (
|
||||
ATTR_DEVICE_NAME,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_NO_LEGACY_ENCRYPTION,
|
||||
ATTR_OS_VERSION,
|
||||
ATTR_SUPPORTS_ENCRYPTION,
|
||||
CONF_SECRET,
|
||||
@@ -34,7 +35,7 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_decrypt() -> tuple[int, Callable]:
|
||||
def setup_decrypt(key_encoder) -> tuple[int, Callable]:
|
||||
"""Return decryption function and length of key.
|
||||
|
||||
Async friendly.
|
||||
@@ -42,12 +43,14 @@ def setup_decrypt() -> tuple[int, Callable]:
|
||||
|
||||
def decrypt(ciphertext, key):
|
||||
"""Decrypt ciphertext using key."""
|
||||
return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder)
|
||||
return SecretBox(key, encoder=key_encoder).decrypt(
|
||||
ciphertext, encoder=Base64Encoder
|
||||
)
|
||||
|
||||
return (SecretBox.KEY_SIZE, decrypt)
|
||||
|
||||
|
||||
def setup_encrypt() -> tuple[int, Callable]:
|
||||
def setup_encrypt(key_encoder) -> tuple[int, Callable]:
|
||||
"""Return encryption function and length of key.
|
||||
|
||||
Async friendly.
|
||||
@@ -55,15 +58,22 @@ def setup_encrypt() -> tuple[int, Callable]:
|
||||
|
||||
def encrypt(ciphertext, key):
|
||||
"""Encrypt ciphertext using key."""
|
||||
return SecretBox(key).encrypt(ciphertext, encoder=Base64Encoder)
|
||||
return SecretBox(key, encoder=key_encoder).encrypt(
|
||||
ciphertext, encoder=Base64Encoder
|
||||
)
|
||||
|
||||
return (SecretBox.KEY_SIZE, encrypt)
|
||||
|
||||
|
||||
def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
def _decrypt_payload_helper(
|
||||
key: str | None,
|
||||
ciphertext: str,
|
||||
get_key_bytes: Callable[[str, int], str | bytes],
|
||||
key_encoder,
|
||||
) -> dict[str, str] | None:
|
||||
"""Decrypt encrypted payload."""
|
||||
try:
|
||||
keylen, decrypt = setup_decrypt()
|
||||
keylen, decrypt = setup_decrypt(key_encoder)
|
||||
except OSError:
|
||||
_LOGGER.warning("Ignoring encrypted payload because libsodium not installed")
|
||||
return None
|
||||
@@ -72,18 +82,33 @@ def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
_LOGGER.warning("Ignoring encrypted payload because no decryption key known")
|
||||
return None
|
||||
|
||||
key_bytes = key.encode("utf-8")
|
||||
key_bytes = key_bytes[:keylen]
|
||||
key_bytes = key_bytes.ljust(keylen, b"\0")
|
||||
key_bytes = get_key_bytes(key, keylen)
|
||||
|
||||
try:
|
||||
msg_bytes = decrypt(ciphertext, key_bytes)
|
||||
message = json.loads(msg_bytes.decode("utf-8"))
|
||||
_LOGGER.debug("Successfully decrypted mobile_app payload")
|
||||
return message
|
||||
except ValueError:
|
||||
_LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
|
||||
return None
|
||||
msg_bytes = decrypt(ciphertext, key_bytes)
|
||||
message = json.loads(msg_bytes.decode("utf-8"))
|
||||
_LOGGER.debug("Successfully decrypted mobile_app payload")
|
||||
return message
|
||||
|
||||
|
||||
def _decrypt_payload(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
"""Decrypt encrypted payload."""
|
||||
|
||||
def get_key_bytes(key: str, keylen: int) -> str:
|
||||
return key
|
||||
|
||||
return _decrypt_payload_helper(key, ciphertext, get_key_bytes, HexEncoder)
|
||||
|
||||
|
||||
def _decrypt_payload_legacy(key: str | None, ciphertext: str) -> dict[str, str] | None:
|
||||
"""Decrypt encrypted payload."""
|
||||
|
||||
def get_key_bytes(key: str, keylen: int) -> bytes:
|
||||
key_bytes = key.encode("utf-8")
|
||||
key_bytes = key_bytes[:keylen]
|
||||
key_bytes = key_bytes.ljust(keylen, b"\0")
|
||||
return key_bytes
|
||||
|
||||
return _decrypt_payload_helper(key, ciphertext, get_key_bytes, RawEncoder)
|
||||
|
||||
|
||||
def registration_context(registration: dict) -> Context:
|
||||
@@ -158,11 +183,16 @@ def webhook_response(
|
||||
data = json.dumps(data, cls=JSONEncoder)
|
||||
|
||||
if registration[ATTR_SUPPORTS_ENCRYPTION]:
|
||||
keylen, encrypt = setup_encrypt()
|
||||
keylen, encrypt = setup_encrypt(
|
||||
HexEncoder if ATTR_NO_LEGACY_ENCRYPTION in registration else RawEncoder
|
||||
)
|
||||
|
||||
key = registration[CONF_SECRET].encode("utf-8")
|
||||
key = key[:keylen]
|
||||
key = key.ljust(keylen, b"\0")
|
||||
if ATTR_NO_LEGACY_ENCRYPTION in registration:
|
||||
key = registration[CONF_SECRET]
|
||||
else:
|
||||
key = registration[CONF_SECRET].encode("utf-8")
|
||||
key = key[:keylen]
|
||||
key = key.ljust(keylen, b"\0")
|
||||
|
||||
enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8")
|
||||
data = json.dumps({"encrypted": True, "encrypted_data": enc_data})
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import secrets
|
||||
|
||||
from aiohttp.web import HTTPBadRequest, Request, Response, json_response
|
||||
from nacl.exceptions import CryptoError
|
||||
from nacl.secret import SecretBox
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -58,6 +59,7 @@ from .const import (
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_MANUFACTURER,
|
||||
ATTR_MODEL,
|
||||
ATTR_NO_LEGACY_ENCRYPTION,
|
||||
ATTR_OS_VERSION,
|
||||
ATTR_SENSOR_ATTRIBUTES,
|
||||
ATTR_SENSOR_DEVICE_CLASS,
|
||||
@@ -97,6 +99,7 @@ from .const import (
|
||||
)
|
||||
from .helpers import (
|
||||
_decrypt_payload,
|
||||
_decrypt_payload_legacy,
|
||||
empty_okay_response,
|
||||
error_response,
|
||||
registration_context,
|
||||
@@ -191,7 +194,27 @@ async def handle_webhook(
|
||||
|
||||
if req_data[ATTR_WEBHOOK_ENCRYPTED]:
|
||||
enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA]
|
||||
webhook_payload = _decrypt_payload(config_entry.data[CONF_SECRET], enc_data)
|
||||
try:
|
||||
webhook_payload = _decrypt_payload(config_entry.data[CONF_SECRET], enc_data)
|
||||
if ATTR_NO_LEGACY_ENCRYPTION not in config_entry.data:
|
||||
data = {**config_entry.data, ATTR_NO_LEGACY_ENCRYPTION: True}
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
except CryptoError:
|
||||
if ATTR_NO_LEGACY_ENCRYPTION not in config_entry.data:
|
||||
try:
|
||||
webhook_payload = _decrypt_payload_legacy(
|
||||
config_entry.data[CONF_SECRET], enc_data
|
||||
)
|
||||
except CryptoError:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because unable to decrypt"
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Ignoring invalid encrypted payload")
|
||||
else:
|
||||
_LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
|
||||
except ValueError:
|
||||
_LOGGER.warning("Ignoring invalid encrypted payload")
|
||||
|
||||
if webhook_type not in WEBHOOK_COMMANDS:
|
||||
_LOGGER.error(
|
||||
|
||||
@@ -209,7 +209,7 @@ def duplicate_entity_validator(config: dict) -> dict:
|
||||
addr += "_" + str(entry[CONF_COMMAND_ON])
|
||||
if CONF_COMMAND_OFF in entry:
|
||||
addr += "_" + str(entry[CONF_COMMAND_OFF])
|
||||
addr += "_" + str(entry[CONF_SLAVE])
|
||||
addr += "_" + str(entry.get(CONF_SLAVE, 0))
|
||||
if addr in addresses:
|
||||
err = f"Modbus {component}/{name} address {addr} is duplicate, second entry not loaded!"
|
||||
_LOGGER.warning(err)
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
from operator import attrgetter
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Union, cast
|
||||
import uuid
|
||||
|
||||
import attr
|
||||
@@ -75,11 +75,16 @@ from .const import (
|
||||
ATTR_TOPIC,
|
||||
CONF_BIRTH_MESSAGE,
|
||||
CONF_BROKER,
|
||||
CONF_CERTIFICATE,
|
||||
CONF_CLIENT_CERT,
|
||||
CONF_CLIENT_KEY,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_TLS_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_WILL_MESSAGE,
|
||||
DATA_MQTT_CONFIG,
|
||||
@@ -94,6 +99,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
MQTT_CONNECTED,
|
||||
MQTT_DISCONNECTED,
|
||||
PROTOCOL_31,
|
||||
PROTOCOL_311,
|
||||
)
|
||||
from .discovery import LAST_DISCOVERY
|
||||
@@ -107,6 +113,11 @@ from .models import (
|
||||
)
|
||||
from .util import _VALID_QOS_SCHEMA, valid_publish_topic, valid_subscribe_topic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Only import for paho-mqtt type checking here, imports are done locally
|
||||
# because integrations should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SENTINEL = object()
|
||||
@@ -118,19 +129,19 @@ SERVICE_DUMP = "dump"
|
||||
|
||||
CONF_DISCOVERY_PREFIX = "discovery_prefix"
|
||||
CONF_KEEPALIVE = "keepalive"
|
||||
CONF_CERTIFICATE = "certificate"
|
||||
CONF_CLIENT_KEY = "client_key"
|
||||
CONF_CLIENT_CERT = "client_cert"
|
||||
CONF_TLS_INSECURE = "tls_insecure"
|
||||
CONF_TLS_VERSION = "tls_version"
|
||||
|
||||
PROTOCOL_31 = "3.1"
|
||||
|
||||
DEFAULT_PORT = 1883
|
||||
DEFAULT_KEEPALIVE = 60
|
||||
DEFAULT_PROTOCOL = PROTOCOL_311
|
||||
DEFAULT_TLS_PROTOCOL = "auto"
|
||||
|
||||
DEFAULT_VALUES = {
|
||||
CONF_PORT: DEFAULT_PORT,
|
||||
CONF_WILL_MESSAGE: DEFAULT_WILL,
|
||||
CONF_BIRTH_MESSAGE: DEFAULT_BIRTH,
|
||||
CONF_DISCOVERY: DEFAULT_DISCOVERY,
|
||||
}
|
||||
|
||||
ATTR_TOPIC_TEMPLATE = "topic_template"
|
||||
ATTR_PAYLOAD_TEMPLATE = "payload_template"
|
||||
|
||||
@@ -186,7 +197,7 @@ CONFIG_SCHEMA_BASE = vol.Schema(
|
||||
vol.Coerce(int), vol.Range(min=15)
|
||||
),
|
||||
vol.Optional(CONF_BROKER): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile),
|
||||
@@ -203,9 +214,9 @@ CONFIG_SCHEMA_BASE = vol.Schema(
|
||||
vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All(
|
||||
cv.string, vol.In([PROTOCOL_31, PROTOCOL_311])
|
||||
),
|
||||
vol.Optional(CONF_WILL_MESSAGE, default=DEFAULT_WILL): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_BIRTH_MESSAGE, default=DEFAULT_BIRTH): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
|
||||
vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
|
||||
vol.Optional(CONF_DISCOVERY): cv.boolean,
|
||||
# discovery_prefix must be a valid publish topic because if no
|
||||
# state topic is specified, it will be created with the given prefix.
|
||||
vol.Optional(
|
||||
@@ -609,6 +620,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
def _merge_config(entry, conf):
|
||||
"""Merge configuration.yaml config with config entry."""
|
||||
# Base config on default values
|
||||
conf = {**DEFAULT_VALUES, **conf}
|
||||
return {**conf, **entry.data}
|
||||
|
||||
|
||||
@@ -628,6 +641,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
override,
|
||||
)
|
||||
|
||||
# Merge the configuration values from configuration.yaml
|
||||
conf = _merge_config(entry, conf)
|
||||
|
||||
hass.data[DATA_MQTT] = MQTT(
|
||||
@@ -757,6 +771,58 @@ class Subscription:
|
||||
encoding: str | None = attr.ib(default="utf-8")
|
||||
|
||||
|
||||
class MqttClientSetup:
|
||||
"""Helper class to setup the paho mqtt client from config."""
|
||||
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the MQTT client setup helper."""
|
||||
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
if config[CONF_PROTOCOL] == PROTOCOL_31:
|
||||
proto = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
if (client_id := config.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
|
||||
self._client = mqtt.Client(client_id, protocol=proto)
|
||||
|
||||
# Enable logging
|
||||
self._client.enable_logger()
|
||||
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
if username is not None:
|
||||
self._client.username_pw_set(username, password)
|
||||
|
||||
if (certificate := config.get(CONF_CERTIFICATE)) == "auto":
|
||||
certificate = certifi.where()
|
||||
|
||||
client_key = config.get(CONF_CLIENT_KEY)
|
||||
client_cert = config.get(CONF_CLIENT_CERT)
|
||||
tls_insecure = config.get(CONF_TLS_INSECURE)
|
||||
if certificate is not None:
|
||||
self._client.tls_set(
|
||||
certificate,
|
||||
certfile=client_cert,
|
||||
keyfile=client_key,
|
||||
tls_version=ssl.PROTOCOL_TLS,
|
||||
)
|
||||
|
||||
if tls_insecure is not None:
|
||||
self._client.tls_insecure_set(tls_insecure)
|
||||
|
||||
@property
|
||||
def client(self) -> mqtt.Client:
|
||||
"""Return the paho MQTT client."""
|
||||
return self._client
|
||||
|
||||
|
||||
class MQTT:
|
||||
"""Home Assistant MQTT client."""
|
||||
|
||||
@@ -821,46 +887,7 @@ class MQTT:
|
||||
|
||||
def init_client(self):
|
||||
"""Initialize paho client."""
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
if self.conf[CONF_PROTOCOL] == PROTOCOL_31:
|
||||
proto: int = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
if (client_id := self.conf.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = mqtt.base62(uuid.uuid4().int, padding=22)
|
||||
self._mqttc = mqtt.Client(client_id, protocol=proto)
|
||||
|
||||
# Enable logging
|
||||
self._mqttc.enable_logger()
|
||||
|
||||
username = self.conf.get(CONF_USERNAME)
|
||||
password = self.conf.get(CONF_PASSWORD)
|
||||
if username is not None:
|
||||
self._mqttc.username_pw_set(username, password)
|
||||
|
||||
if (certificate := self.conf.get(CONF_CERTIFICATE)) == "auto":
|
||||
certificate = certifi.where()
|
||||
|
||||
client_key = self.conf.get(CONF_CLIENT_KEY)
|
||||
client_cert = self.conf.get(CONF_CLIENT_CERT)
|
||||
tls_insecure = self.conf.get(CONF_TLS_INSECURE)
|
||||
if certificate is not None:
|
||||
self._mqttc.tls_set(
|
||||
certificate,
|
||||
certfile=client_cert,
|
||||
keyfile=client_key,
|
||||
tls_version=ssl.PROTOCOL_TLS,
|
||||
)
|
||||
|
||||
if tls_insecure is not None:
|
||||
self._mqttc.tls_insecure_set(tls_insecure)
|
||||
|
||||
self._mqttc = MqttClientSetup(self.conf).client
|
||||
self._mqttc.on_connect = self._mqtt_on_connect
|
||||
self._mqttc.on_disconnect = self._mqtt_on_disconnect
|
||||
self._mqttc.on_message = self._mqtt_on_message
|
||||
|
||||
@@ -271,7 +271,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
|
||||
vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_HOLD_LIST, default=list): cv.ensure_list,
|
||||
vol.Optional(CONF_HOLD_LIST): cv.ensure_list,
|
||||
vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(
|
||||
@@ -298,7 +298,7 @@ _PLATFORM_SCHEMA_BASE = SCHEMA_BASE.extend(
|
||||
),
|
||||
vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean,
|
||||
vol.Optional(CONF_SEND_IF_OFF): cv.boolean,
|
||||
vol.Optional(CONF_ACTION_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_ACTION_TOPIC): mqtt.valid_subscribe_topic,
|
||||
# CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together
|
||||
@@ -431,6 +431,12 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
self._feature_preset_mode = False
|
||||
self._optimistic_preset_mode = None
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
self._send_if_off = True
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
self._hold_list = []
|
||||
|
||||
MqttEntity.__init__(self, hass, config, config_entry, discovery_data)
|
||||
|
||||
@staticmethod
|
||||
@@ -499,6 +505,15 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
|
||||
self._command_templates = command_templates
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if CONF_SEND_IF_OFF in config:
|
||||
self._send_if_off = config[CONF_SEND_IF_OFF]
|
||||
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
if CONF_HOLD_LIST in config:
|
||||
self._hold_list = config[CONF_HOLD_LIST]
|
||||
|
||||
def _prepare_subscribe_topics(self): # noqa: C901
|
||||
"""(Re)Subscribe to topics."""
|
||||
topics = {}
|
||||
@@ -806,7 +821,9 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
):
|
||||
presets.append(PRESET_AWAY)
|
||||
|
||||
presets.extend(self._config[CONF_HOLD_LIST])
|
||||
# AWAY and HOLD mode topics and templates are deprecated,
|
||||
# support will be removed with release 2022.9
|
||||
presets.extend(self._hold_list)
|
||||
|
||||
if presets:
|
||||
presets.insert(0, PRESET_NONE)
|
||||
@@ -847,10 +864,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
setattr(self, attr, temp)
|
||||
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if (
|
||||
self._config[CONF_SEND_IF_OFF]
|
||||
or self._current_operation != HVAC_MODE_OFF
|
||||
):
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[cmnd_template](temp)
|
||||
await self._publish(cmnd_topic, payload)
|
||||
|
||||
@@ -890,7 +904,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new swing mode."""
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](
|
||||
swing_mode
|
||||
)
|
||||
@@ -903,7 +917,7 @@ class MqttClimate(MqttEntity, ClimateEntity):
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target temperature."""
|
||||
# CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9
|
||||
if self._config[CONF_SEND_IF_OFF] or self._current_operation != HVAC_MODE_OFF:
|
||||
if self._send_if_off or self._current_operation != HVAC_MODE_OFF:
|
||||
payload = self._command_templates[CONF_FAN_MODE_COMMAND_TEMPLATE](fan_mode)
|
||||
await self._publish(CONF_FAN_MODE_COMMAND_TOPIC, payload)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import MqttClientSetup
|
||||
from .const import (
|
||||
ATTR_PAYLOAD,
|
||||
ATTR_QOS,
|
||||
@@ -62,6 +63,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
user_input[CONF_BROKER],
|
||||
user_input[CONF_PORT],
|
||||
user_input.get(CONF_USERNAME),
|
||||
@@ -102,6 +104,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
data = self._hassio_discovery
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
data[CONF_HOST],
|
||||
data[CONF_PORT],
|
||||
data.get(CONF_USERNAME),
|
||||
@@ -152,6 +155,7 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
if user_input is not None:
|
||||
can_connect = await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
self.hass,
|
||||
user_input[CONF_BROKER],
|
||||
user_input[CONF_PORT],
|
||||
user_input.get(CONF_USERNAME),
|
||||
@@ -313,19 +317,22 @@ class MQTTOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
def try_connection(broker, port, username, password, protocol="3.1"):
|
||||
def try_connection(hass, broker, port, username, password, protocol="3.1"):
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
import paho.mqtt.client as mqtt
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
if protocol == "3.1":
|
||||
proto = mqtt.MQTTv31
|
||||
else:
|
||||
proto = mqtt.MQTTv311
|
||||
|
||||
client = mqtt.Client(protocol=proto)
|
||||
if username and password:
|
||||
client.username_pw_set(username, password)
|
||||
# Get the config from configuration.yaml
|
||||
yaml_config = hass.data.get(DATA_MQTT_CONFIG, {})
|
||||
entry_config = {
|
||||
CONF_BROKER: broker,
|
||||
CONF_PORT: port,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_PROTOCOL: protocol,
|
||||
}
|
||||
client = MqttClientSetup({**yaml_config, **entry_config}).client
|
||||
|
||||
result = queue.Queue(maxsize=1)
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ CONF_STATE_VALUE_TEMPLATE = "state_value_template"
|
||||
CONF_TOPIC = "topic"
|
||||
CONF_WILL_MESSAGE = "will_message"
|
||||
|
||||
CONF_CERTIFICATE = "certificate"
|
||||
CONF_CLIENT_KEY = "client_key"
|
||||
CONF_CLIENT_CERT = "client_cert"
|
||||
CONF_TLS_INSECURE = "tls_insecure"
|
||||
CONF_TLS_VERSION = "tls_version"
|
||||
|
||||
DATA_MQTT_CONFIG = "mqtt_config"
|
||||
DATA_MQTT_RELOAD_NEEDED = "mqtt_reload_needed"
|
||||
|
||||
@@ -56,4 +62,5 @@ MQTT_DISCONNECTED = "mqtt_disconnected"
|
||||
PAYLOAD_EMPTY_JSON = "{}"
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
PROTOCOL_31 = "3.1"
|
||||
PROTOCOL_311 = "3.1.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "obihai",
|
||||
"name": "Obihai",
|
||||
"documentation": "https://www.home-assistant.io/integrations/obihai",
|
||||
"requirements": ["pyobihai==1.3.1"],
|
||||
"requirements": ["pyobihai==1.3.2"],
|
||||
"codeowners": ["@dshokouhi"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyobihai"]
|
||||
|
||||
@@ -43,7 +43,7 @@ async def async_setup_platform(
|
||||
station_id = config[CONF_STATION_ID]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
osm_api = OpenSenseMapData(OpenSenseMap(station_id, hass.loop, session))
|
||||
osm_api = OpenSenseMapData(OpenSenseMap(station_id, session))
|
||||
|
||||
await osm_api.async_update()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "opensensemap",
|
||||
"name": "openSenseMap",
|
||||
"documentation": "https://www.home-assistant.io/integrations/opensensemap",
|
||||
"requirements": ["opensensemap-api==0.1.5"],
|
||||
"requirements": ["opensensemap-api==0.2.0"],
|
||||
"codeowners": [],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opensensemap_api"]
|
||||
|
||||
@@ -97,6 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
token_saver=token_saver,
|
||||
)
|
||||
try:
|
||||
# pylint: disable-next=fixme
|
||||
# TODO Remove authlib constraint when refactoring this code
|
||||
await session.ensure_active_token()
|
||||
except ConnectTimeout as err:
|
||||
_LOGGER.debug("Connection Timeout")
|
||||
|
||||
@@ -110,6 +110,15 @@ class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity):
|
||||
_attr_name = "Powerwall Charging"
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Powerwall is available."""
|
||||
# Return False if no battery is installed
|
||||
return (
|
||||
super().available
|
||||
and self.data.meters.get_meter(MeterType.BATTERY) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Device Uniqueid."""
|
||||
|
||||
@@ -114,7 +114,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity):
|
||||
class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity):
|
||||
"""Representation of an Powerwall Direction Energy sensor."""
|
||||
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
_attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR
|
||||
_attr_device_class = SensorDeviceClass.ENERGY
|
||||
|
||||
@@ -160,7 +160,7 @@ class PowerWallExportSensor(PowerWallEnergyDirectionSensor):
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Get the current value in kWh."""
|
||||
return abs(self.meter.get_energy_exported())
|
||||
return self.meter.get_energy_exported()
|
||||
|
||||
|
||||
class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
|
||||
@@ -177,4 +177,4 @@ class PowerWallImportSensor(PowerWallEnergyDirectionSensor):
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Get the current value in kWh."""
|
||||
return abs(self.meter.get_energy_imported())
|
||||
return self.meter.get_energy_imported()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Radio Browser",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/radio",
|
||||
"requirements": ["radios==0.1.0"],
|
||||
"requirements": ["radios==0.1.1"],
|
||||
"codeowners": ["@frenck"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.media_player.const import (
|
||||
MEDIA_TYPE_MUSIC,
|
||||
)
|
||||
from homeassistant.components.media_player.errors import BrowseError
|
||||
from homeassistant.components.media_source.error import Unresolvable
|
||||
from homeassistant.components.media_source.models import (
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
@@ -35,9 +36,8 @@ async def async_get_media_source(hass: HomeAssistant) -> RadioMediaSource:
|
||||
"""Set up Radio Browser media source."""
|
||||
# Radio browser support only a single config entry
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
radios = hass.data[DOMAIN]
|
||||
|
||||
return RadioMediaSource(hass, radios, entry)
|
||||
return RadioMediaSource(hass, entry)
|
||||
|
||||
|
||||
class RadioMediaSource(MediaSource):
|
||||
@@ -45,26 +45,33 @@ class RadioMediaSource(MediaSource):
|
||||
|
||||
name = "Radio Browser"
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, radios: RadioBrowser, entry: ConfigEntry
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize CameraMediaSource."""
|
||||
super().__init__(DOMAIN)
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.radios = radios
|
||||
|
||||
@property
|
||||
def radios(self) -> RadioBrowser | None:
|
||||
"""Return the radio browser."""
|
||||
return self.hass.data.get(DOMAIN)
|
||||
|
||||
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve selected Radio station to a streaming URL."""
|
||||
station = await self.radios.station(uuid=item.identifier)
|
||||
radios = self.radios
|
||||
|
||||
if radios is None:
|
||||
raise Unresolvable("Radio Browser not initialized")
|
||||
|
||||
station = await radios.station(uuid=item.identifier)
|
||||
if not station:
|
||||
raise BrowseError("Radio station is no longer available")
|
||||
raise Unresolvable("Radio station is no longer available")
|
||||
|
||||
if not (mime_type := self._async_get_station_mime_type(station)):
|
||||
raise BrowseError("Could not determine stream type of radio station")
|
||||
raise Unresolvable("Could not determine stream type of radio station")
|
||||
|
||||
# Register "click" with Radio Browser
|
||||
await self.radios.station_click(uuid=station.uuid)
|
||||
await radios.station_click(uuid=station.uuid)
|
||||
|
||||
return PlayMedia(station.url, mime_type)
|
||||
|
||||
@@ -73,6 +80,11 @@ class RadioMediaSource(MediaSource):
|
||||
item: MediaSourceItem,
|
||||
) -> BrowseMediaSource:
|
||||
"""Return media."""
|
||||
radios = self.radios
|
||||
|
||||
if radios is None:
|
||||
raise BrowseError("Radio Browser not initialized")
|
||||
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=None,
|
||||
@@ -83,10 +95,10 @@ class RadioMediaSource(MediaSource):
|
||||
can_expand=True,
|
||||
children_media_class=MEDIA_CLASS_DIRECTORY,
|
||||
children=[
|
||||
*await self._async_build_popular(item),
|
||||
*await self._async_build_by_tag(item),
|
||||
*await self._async_build_by_language(item),
|
||||
*await self._async_build_by_country(item),
|
||||
*await self._async_build_popular(radios, item),
|
||||
*await self._async_build_by_tag(radios, item),
|
||||
*await self._async_build_by_language(radios, item),
|
||||
*await self._async_build_by_country(radios, item),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -100,7 +112,9 @@ class RadioMediaSource(MediaSource):
|
||||
return mime_type
|
||||
|
||||
@callback
|
||||
def _async_build_stations(self, stations: list[Station]) -> list[BrowseMediaSource]:
|
||||
def _async_build_stations(
|
||||
self, radios: RadioBrowser, stations: list[Station]
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Build list of media sources from radio stations."""
|
||||
items: list[BrowseMediaSource] = []
|
||||
|
||||
@@ -126,23 +140,23 @@ class RadioMediaSource(MediaSource):
|
||||
return items
|
||||
|
||||
async def _async_build_by_country(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing radio stations by country."""
|
||||
category, _, country_code = (item.identifier or "").partition("/")
|
||||
if country_code:
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
filter_by=FilterBy.COUNTRY_CODE_EXACT,
|
||||
filter_term=country_code,
|
||||
hide_broken=True,
|
||||
order=Order.NAME,
|
||||
reverse=False,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
# We show country in the root additionally, when there is no item
|
||||
if not item.identifier or category == "country":
|
||||
countries = await self.radios.countries(order=Order.NAME)
|
||||
countries = await radios.countries(order=Order.NAME)
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
@@ -160,22 +174,22 @@ class RadioMediaSource(MediaSource):
|
||||
return []
|
||||
|
||||
async def _async_build_by_language(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing radio stations by language."""
|
||||
category, _, language = (item.identifier or "").partition("/")
|
||||
if category == "language" and language:
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
filter_by=FilterBy.LANGUAGE_EXACT,
|
||||
filter_term=language,
|
||||
hide_broken=True,
|
||||
order=Order.NAME,
|
||||
reverse=False,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
if category == "language":
|
||||
languages = await self.radios.languages(order=Order.NAME, hide_broken=True)
|
||||
languages = await radios.languages(order=Order.NAME, hide_broken=True)
|
||||
return [
|
||||
BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
@@ -206,17 +220,17 @@ class RadioMediaSource(MediaSource):
|
||||
return []
|
||||
|
||||
async def _async_build_popular(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing popular radio stations."""
|
||||
if item.identifier == "popular":
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
hide_broken=True,
|
||||
limit=250,
|
||||
order=Order.CLICK_COUNT,
|
||||
reverse=True,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
if not item.identifier:
|
||||
return [
|
||||
@@ -234,22 +248,22 @@ class RadioMediaSource(MediaSource):
|
||||
return []
|
||||
|
||||
async def _async_build_by_tag(
|
||||
self, item: MediaSourceItem
|
||||
self, radios: RadioBrowser, item: MediaSourceItem
|
||||
) -> list[BrowseMediaSource]:
|
||||
"""Handle browsing radio stations by tags."""
|
||||
category, _, tag = (item.identifier or "").partition("/")
|
||||
if category == "tag" and tag:
|
||||
stations = await self.radios.stations(
|
||||
stations = await radios.stations(
|
||||
filter_by=FilterBy.TAG_EXACT,
|
||||
filter_term=tag,
|
||||
hide_broken=True,
|
||||
order=Order.NAME,
|
||||
reverse=False,
|
||||
)
|
||||
return self._async_build_stations(stations)
|
||||
return self._async_build_stations(radios, stations)
|
||||
|
||||
if category == "tag":
|
||||
tags = await self.radios.tags(
|
||||
tags = await radios.tags(
|
||||
hide_broken=True,
|
||||
limit=100,
|
||||
order=Order.STATION_COUNT,
|
||||
|
||||
@@ -47,7 +47,7 @@ send_command:
|
||||
required: true
|
||||
example: "Play"
|
||||
selector:
|
||||
text:
|
||||
object:
|
||||
num_repeats:
|
||||
name: Repeats
|
||||
description: The number of times you want to repeat the command(s).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/renault",
|
||||
"requirements": [
|
||||
"renault-api==0.1.9"
|
||||
"renault-api==0.1.10"
|
||||
],
|
||||
"codeowners": [
|
||||
"@epenet"
|
||||
|
||||
@@ -104,7 +104,7 @@ class RenaultVehicleProxy:
|
||||
coordinator = self.coordinators[key]
|
||||
if coordinator.not_supported:
|
||||
# Remove endpoint as it is not supported for this vehicle.
|
||||
LOGGER.warning(
|
||||
LOGGER.info(
|
||||
"Ignoring endpoint %s as it is not supported for this vehicle: %s",
|
||||
coordinator.name,
|
||||
coordinator.last_exception,
|
||||
@@ -112,7 +112,7 @@ class RenaultVehicleProxy:
|
||||
del self.coordinators[key]
|
||||
elif coordinator.access_denied:
|
||||
# Remove endpoint as it is denied for this vehicle.
|
||||
LOGGER.warning(
|
||||
LOGGER.info(
|
||||
"Ignoring endpoint %s as it is denied for this vehicle: %s",
|
||||
coordinator.name,
|
||||
coordinator.last_exception,
|
||||
|
||||
@@ -6,7 +6,7 @@ import binascii
|
||||
from collections.abc import Callable
|
||||
import copy
|
||||
import logging
|
||||
from typing import NamedTuple
|
||||
from typing import NamedTuple, cast
|
||||
|
||||
import RFXtrx as rfxtrxmod
|
||||
import async_timeout
|
||||
@@ -229,11 +229,7 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry):
|
||||
devices[device_id] = config
|
||||
|
||||
@callback
|
||||
def _remove_device(event: Event):
|
||||
if event.data["action"] != "remove":
|
||||
return
|
||||
device_entry = device_registry.deleted_devices[event.data["device_id"]]
|
||||
device_id = next(iter(device_entry.identifiers))[1:]
|
||||
def _remove_device(device_id: DeviceTuple):
|
||||
data = {
|
||||
**entry.data,
|
||||
CONF_DEVICES: {
|
||||
@@ -245,8 +241,19 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry):
|
||||
hass.config_entries.async_update_entry(entry=entry, data=data)
|
||||
devices.pop(device_id)
|
||||
|
||||
@callback
|
||||
def _updated_device(event: Event):
|
||||
if event.data["action"] != "remove":
|
||||
return
|
||||
device_entry = device_registry.deleted_devices[event.data["device_id"]]
|
||||
if entry.entry_id not in device_entry.config_entries:
|
||||
return
|
||||
device_id = get_device_tuple_from_identifiers(device_entry.identifiers)
|
||||
if device_id:
|
||||
_remove_device(device_id)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, _remove_device)
|
||||
hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, _updated_device)
|
||||
)
|
||||
|
||||
def _shutdown_rfxtrx(event):
|
||||
@@ -413,6 +420,18 @@ def get_device_id(
|
||||
return DeviceTuple(f"{device.packettype:x}", f"{device.subtype:x}", id_string)
|
||||
|
||||
|
||||
def get_device_tuple_from_identifiers(
|
||||
identifiers: set[tuple[str, str]]
|
||||
) -> DeviceTuple | None:
|
||||
"""Calculate the device tuple from a device entry."""
|
||||
identifier = next((x for x in identifiers if x[0] == DOMAIN), None)
|
||||
if not identifier:
|
||||
return None
|
||||
# work around legacy identifier, being a multi tuple value
|
||||
identifier2 = cast(tuple[str, str, str, str], identifier)
|
||||
return DeviceTuple(identifier2[1], identifier2[2], identifier2[3])
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
|
||||
) -> bool:
|
||||
|
||||
@@ -34,7 +34,13 @@ from homeassistant.helpers.entity_registry import (
|
||||
async_get_registry as async_get_entity_registry,
|
||||
)
|
||||
|
||||
from . import DOMAIN, DeviceTuple, get_device_id, get_rfx_object
|
||||
from . import (
|
||||
DOMAIN,
|
||||
DeviceTuple,
|
||||
get_device_id,
|
||||
get_device_tuple_from_identifiers,
|
||||
get_rfx_object,
|
||||
)
|
||||
from .binary_sensor import supported as binary_supported
|
||||
from .const import (
|
||||
CONF_AUTOMATIC_ADD,
|
||||
@@ -59,7 +65,7 @@ CONF_MANUAL_PATH = "Enter Manually"
|
||||
class DeviceData(TypedDict):
|
||||
"""Dict data representing a device entry."""
|
||||
|
||||
event_code: str
|
||||
event_code: str | None
|
||||
device_id: DeviceTuple
|
||||
|
||||
|
||||
@@ -388,15 +394,15 @@ class OptionsFlow(config_entries.OptionsFlow):
|
||||
|
||||
def _get_device_data(self, entry_id) -> DeviceData:
|
||||
"""Get event code based on device identifier."""
|
||||
event_code: str
|
||||
event_code: str | None = None
|
||||
entry = self._device_registry.async_get(entry_id)
|
||||
assert entry
|
||||
device_id = cast(DeviceTuple, next(iter(entry.identifiers))[1:])
|
||||
device_id = get_device_tuple_from_identifiers(entry.identifiers)
|
||||
assert device_id
|
||||
for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items():
|
||||
if tuple(entity_info.get(CONF_DEVICE_ID)) == device_id:
|
||||
event_code = cast(str, packet_id)
|
||||
break
|
||||
assert event_code
|
||||
return DeviceData(event_code=event_code, device_id=device_id)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "rfxtrx",
|
||||
"name": "RFXCOM RFXtrx",
|
||||
"documentation": "https://www.home-assistant.io/integrations/rfxtrx",
|
||||
"requirements": ["pyRFXtrx==0.27.1"],
|
||||
"requirements": ["pyRFXtrx==0.28.0"],
|
||||
"codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"""Support for Roku."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from rokuecp import RokuConnectionError, RokuError
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -16,7 +8,6 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
|
||||
@@ -27,10 +18,6 @@ PLATFORMS = [
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T", bound="RokuEntity")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -53,22 +40,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
def roku_exception_handler(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]] # type: ignore[misc]
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
|
||||
"""Decorate Roku calls to handle Roku exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except RokuConnectionError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"""Helpers for Roku."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
from .entity import RokuEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T", bound=RokuEntity)
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
def format_channel_name(channel_number: str, channel_name: str | None = None) -> str:
|
||||
"""Format a Roku Channel name."""
|
||||
@@ -8,3 +23,28 @@ def format_channel_name(channel_number: str, channel_name: str | None = None) ->
|
||||
return f"{channel_name} ({channel_number})"
|
||||
|
||||
return channel_number
|
||||
|
||||
|
||||
def roku_exception_handler(ignore_timeout: bool = False) -> Callable[..., Callable]:
|
||||
"""Decorate Roku calls to handle Roku exceptions."""
|
||||
|
||||
def decorator(
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]], # type: ignore[misc]
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: # type: ignore[misc]
|
||||
@wraps(func)
|
||||
async def wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except RokuConnectionTimeoutError as error:
|
||||
if not ignore_timeout and self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuConnectionError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Error communicating with API: %s", error)
|
||||
except RokuError as error:
|
||||
if self.available:
|
||||
_LOGGER.error("Invalid response from API: %s", error)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "roku",
|
||||
"name": "Roku",
|
||||
"documentation": "https://www.home-assistant.io/integrations/roku",
|
||||
"requirements": ["rokuecp==0.14.1"],
|
||||
"requirements": ["rokuecp==0.15.0"],
|
||||
"homekit": {
|
||||
"models": ["3810X", "4660X", "7820X", "C105X", "C135X"]
|
||||
},
|
||||
|
||||
@@ -51,7 +51,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .browse_media import async_browse_media
|
||||
from .const import (
|
||||
ATTR_ARTIST_NAME,
|
||||
@@ -65,7 +64,7 @@ from .const import (
|
||||
)
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import format_channel_name
|
||||
from .helpers import format_channel_name, roku_exception_handler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -289,7 +288,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
app.name for app in self.coordinator.data.apps if app.name is not None
|
||||
)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def search(self, keyword: str) -> None:
|
||||
"""Emulate opening the search screen and entering the search keyword."""
|
||||
await self.coordinator.roku.search(keyword)
|
||||
@@ -321,68 +320,68 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
media_content_type,
|
||||
)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the Roku."""
|
||||
await self.coordinator.roku.remote("poweron")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler(ignore_timeout=True)
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the Roku."""
|
||||
await self.coordinator.roku.remote("poweroff")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
if self.state not in (STATE_STANDBY, STATE_PAUSED):
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
if self.state not in (STATE_STANDBY, STATE_PLAYING):
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_play_pause(self) -> None:
|
||||
"""Send play/pause command."""
|
||||
if self.state != STATE_STANDBY:
|
||||
await self.coordinator.roku.remote("play")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send previous track command."""
|
||||
await self.coordinator.roku.remote("reverse")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send next track command."""
|
||||
await self.coordinator.roku.remote("forward")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
await self.coordinator.roku.remote("volume_mute")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up media player."""
|
||||
await self.coordinator.roku.remote("volume_up")
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
await self.coordinator.roku.remote("volume_down")
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_play_media(
|
||||
self, media_type: str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
@@ -487,7 +486,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
if source == "Home":
|
||||
|
||||
@@ -9,10 +9,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import roku_exception_handler
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -44,19 +44,19 @@ class RokuRemote(RokuEntity, RemoteEntity):
|
||||
"""Return true if device is on."""
|
||||
return not self.coordinator.data.state.standby
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
await self.coordinator.roku.remote("poweron")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler(ignore_timeout=True)
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
await self.coordinator.roku.remote("poweroff")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send a command to one device."""
|
||||
num_repeats = kwargs[ATTR_NUM_REPEATS]
|
||||
|
||||
@@ -12,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import roku_exception_handler
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
from .helpers import format_channel_name
|
||||
from .helpers import format_channel_name, roku_exception_handler
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -163,7 +162,7 @@ class RokuSelectEntity(RokuEntity, SelectEntity):
|
||||
"""Return a set of selectable options."""
|
||||
return self.entity_description.options_fn(self.coordinator.data)
|
||||
|
||||
@roku_exception_handler
|
||||
@roku_exception_handler()
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the option."""
|
||||
await self.entity_description.set_fn(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "sabnzbd",
|
||||
"name": "SABnzbd",
|
||||
"documentation": "https://www.home-assistant.io/integrations/sabnzbd",
|
||||
"requirements": ["pysabnzbd==1.1.0"],
|
||||
"requirements": ["pysabnzbd==1.1.1"],
|
||||
"dependencies": ["configurator"],
|
||||
"after_dependencies": ["discovery"],
|
||||
"codeowners": [],
|
||||
|
||||
@@ -10,7 +10,7 @@ from samsungctl import Remote
|
||||
from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse
|
||||
from samsungtvws import SamsungTVWS
|
||||
from samsungtvws.exceptions import ConnectionFailure, HttpApiError
|
||||
from websocket import WebSocketException
|
||||
from websocket import WebSocketException, WebSocketTimeoutException
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -318,9 +318,10 @@ class SamsungTVWSBridge(SamsungTVBridge):
|
||||
|
||||
def _get_app_list(self) -> dict[str, str] | None:
|
||||
"""Get installed app list."""
|
||||
if self._app_list is None:
|
||||
if remote := self._get_remote():
|
||||
if self._app_list is None and (remote := self._get_remote()):
|
||||
with contextlib.suppress(TypeError, WebSocketTimeoutException):
|
||||
raw_app_list: list[dict[str, str]] = remote.app_list()
|
||||
LOGGER.debug("Received app list: %s", raw_app_list)
|
||||
self._app_list = {
|
||||
app["name"]: app["appId"]
|
||||
for app in sorted(raw_app_list, key=lambda app: app["name"])
|
||||
|
||||
@@ -10,7 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import ATTR_TRANSITION
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON
|
||||
from homeassistant.const import CONF_PLATFORM, SERVICE_TURN_ON, STATE_UNAVAILABLE
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -117,7 +117,11 @@ class Scene(RestoreEntity):
|
||||
"""Call when the scene is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state is not None:
|
||||
if (
|
||||
state is not None
|
||||
and state.state is not None
|
||||
and state.state != STATE_UNAVAILABLE
|
||||
):
|
||||
self.__last_activated = state.state
|
||||
|
||||
def activate(self, **kwargs: Any) -> None:
|
||||
|
||||
@@ -175,7 +175,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Call a service to reload scripts."""
|
||||
if (conf := await component.async_prepare_reload()) is None:
|
||||
return
|
||||
|
||||
async_get_blueprints(hass).async_reset_cache()
|
||||
await _async_process_config(hass, conf, component)
|
||||
|
||||
async def turn_on_service(service: ServiceCall) -> None:
|
||||
|
||||
@@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
|
||||
|
||||
MAX_POSSIBLE_STEP = 1000
|
||||
|
||||
|
||||
class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""A Sensibo Data Update Coordinator."""
|
||||
@@ -74,7 +76,11 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
.get("values", [0, 1])
|
||||
)
|
||||
if temperatures_list:
|
||||
temperature_step = temperatures_list[1] - temperatures_list[0]
|
||||
diff = MAX_POSSIBLE_STEP
|
||||
for i in range(len(temperatures_list) - 1):
|
||||
if temperatures_list[i + 1] - temperatures_list[i] < diff:
|
||||
diff = temperatures_list[i + 1] - temperatures_list[i]
|
||||
temperature_step = diff
|
||||
|
||||
active_features = list(ac_states)
|
||||
full_features = set()
|
||||
|
||||
@@ -317,4 +317,14 @@ class BlockSleepingClimate(
|
||||
|
||||
if self.device_block and self.block:
|
||||
_LOGGER.debug("Entity %s attached to blocks", self.name)
|
||||
|
||||
assert self.block.channel
|
||||
|
||||
self._preset_modes = [
|
||||
PRESET_NONE,
|
||||
*self.wrapper.device.settings["thermostats"][int(self.block.channel)][
|
||||
"schedule_profile_names"
|
||||
],
|
||||
]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -336,7 +336,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity):
|
||||
ATTR_RGBW_COLOR
|
||||
]
|
||||
|
||||
if ATTR_EFFECT in kwargs:
|
||||
if ATTR_EFFECT in kwargs and ATTR_COLOR_TEMP not in kwargs:
|
||||
# Color effect change - used only in color mode, switch device mode to color
|
||||
set_mode = "color"
|
||||
if self.wrapper.model == "SHBLB-1":
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Shelly",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
||||
"requirements": ["aioshelly==1.0.10"],
|
||||
"requirements": ["aioshelly==1.0.11"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -174,6 +174,7 @@ SENSORS: Final = {
|
||||
value=lambda value: round(value / 1000, 2),
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
available=lambda block: cast(int, block.energy) != -1,
|
||||
),
|
||||
("emeter", "energyReturned"): BlockSensorDescription(
|
||||
key="emeter|energyReturned",
|
||||
@@ -182,6 +183,7 @@ SENSORS: Final = {
|
||||
value=lambda value: round(value / 1000, 2),
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
available=lambda block: cast(int, block.energyReturned) != -1,
|
||||
),
|
||||
("light", "energy"): BlockSensorDescription(
|
||||
key="light|energy",
|
||||
@@ -215,6 +217,15 @@ SENSORS: Final = {
|
||||
icon="mdi:gauge",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
("sensor", "temp"): BlockSensorDescription(
|
||||
key="sensor|temp",
|
||||
name="Temperature",
|
||||
unit_fn=temperature_unit,
|
||||
value=lambda value: round(value, 1),
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
("sensor", "extTemp"): BlockSensorDescription(
|
||||
key="sensor|extTemp",
|
||||
name="Temperature",
|
||||
|
||||
@@ -79,6 +79,7 @@ class SomfyShade(RestoreEntity, CoverEntity):
|
||||
self._attr_unique_id = target_id
|
||||
self._attr_name = name
|
||||
self._reverse = reverse
|
||||
self._attr_is_closed = None
|
||||
self._attr_device_class = device_class
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._target_id)},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Sony Songpal",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/songpal",
|
||||
"requirements": ["python-songpal==0.14"],
|
||||
"requirements": ["python-songpal==0.14.1"],
|
||||
"codeowners": ["@rytilahti", "@shenxn"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Sonos",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||
"requirements": ["soco==0.26.3"],
|
||||
"requirements": ["soco==0.26.4"],
|
||||
"dependencies": ["ssdp"],
|
||||
"after_dependencies": ["plex", "spotify", "zeroconf", "media_source"],
|
||||
"zeroconf": ["_sonos._tcp.local."],
|
||||
|
||||
@@ -172,7 +172,7 @@ class SQLSensor(SensorEntity):
|
||||
else:
|
||||
self._attr_native_value = data
|
||||
|
||||
if not data:
|
||||
if data is None:
|
||||
_LOGGER.warning("%s returned no results", self._query)
|
||||
|
||||
sess.close()
|
||||
|
||||
@@ -101,6 +101,7 @@ class Sun(Entity):
|
||||
self.rising = self.phase = None
|
||||
self._next_change = None
|
||||
|
||||
@callback
|
||||
def update_location(_event):
|
||||
location, elevation = get_astral_location(self.hass)
|
||||
if location == self.location:
|
||||
|
||||
@@ -277,8 +277,6 @@ class TemplateFan(TemplateEntity, FanEntity):
|
||||
"""Turn off the fan."""
|
||||
await self._off_script.async_run(context=self._context)
|
||||
self._state = STATE_OFF
|
||||
self._percentage = 0
|
||||
self._preset_mode = None
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the percentage speed of the fan."""
|
||||
|
||||
@@ -88,7 +88,10 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity):
|
||||
|
||||
# Handle turning to temp mode
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
color_tmp = mired_to_kelvin(int(kwargs[ATTR_COLOR_TEMP]))
|
||||
# Handle temp conversion mireds -> kelvin being slightly outside of valid range
|
||||
kelvin = mired_to_kelvin(int(kwargs[ATTR_COLOR_TEMP]))
|
||||
kelvin_range = self.device.valid_temperature_range
|
||||
color_tmp = max(kelvin_range.min, min(kelvin_range.max, kelvin))
|
||||
_LOGGER.debug("Changing color temp to %s", color_tmp)
|
||||
await self.device.set_color_temp(
|
||||
color_tmp, brightness=brightness, transition=transition
|
||||
|
||||
@@ -78,4 +78,4 @@ class VelbusCover(VelbusEntity, CoverEntity):
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
self._channel.set_position(100 - kwargs[ATTR_POSITION])
|
||||
await self._channel.set_position(100 - kwargs[ATTR_POSITION])
|
||||
|
||||
@@ -14,7 +14,6 @@ from miio import (
|
||||
AirHumidifierMiot,
|
||||
AirHumidifierMjjsq,
|
||||
AirPurifier,
|
||||
AirPurifierMB4,
|
||||
AirPurifierMiot,
|
||||
CleaningDetails,
|
||||
CleaningSummary,
|
||||
@@ -23,10 +22,8 @@ from miio import (
|
||||
DNDStatus,
|
||||
Fan,
|
||||
Fan1C,
|
||||
FanMiot,
|
||||
FanP5,
|
||||
FanP9,
|
||||
FanP10,
|
||||
FanP11,
|
||||
FanZA5,
|
||||
RoborockVacuum,
|
||||
Timer,
|
||||
@@ -52,7 +49,6 @@ from .const import (
|
||||
KEY_DEVICE,
|
||||
MODEL_AIRFRESH_A1,
|
||||
MODEL_AIRFRESH_T2017,
|
||||
MODEL_AIRPURIFIER_3C,
|
||||
MODEL_FAN_1C,
|
||||
MODEL_FAN_P5,
|
||||
MODEL_FAN_P9,
|
||||
@@ -111,10 +107,10 @@ AIR_MONITOR_PLATFORMS = [Platform.AIR_QUALITY, Platform.SENSOR]
|
||||
|
||||
MODEL_TO_CLASS_MAP = {
|
||||
MODEL_FAN_1C: Fan1C,
|
||||
MODEL_FAN_P10: FanP10,
|
||||
MODEL_FAN_P11: FanP11,
|
||||
MODEL_FAN_P9: FanMiot,
|
||||
MODEL_FAN_P10: FanMiot,
|
||||
MODEL_FAN_P11: FanMiot,
|
||||
MODEL_FAN_P5: FanP5,
|
||||
MODEL_FAN_P9: FanP9,
|
||||
MODEL_FAN_ZA5: FanZA5,
|
||||
}
|
||||
|
||||
@@ -314,8 +310,6 @@ async def async_create_miio_device_and_coordinator(
|
||||
device = AirHumidifier(host, token, model=model)
|
||||
migrate = True
|
||||
# Airpurifiers and Airfresh
|
||||
elif model == MODEL_AIRPURIFIER_3C:
|
||||
device = AirPurifierMB4(host, token)
|
||||
elif model in MODELS_PURIFIER_MIOT:
|
||||
device = AirPurifierMiot(host, token)
|
||||
elif model.startswith("zhimi.airpurifier."):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Xiaomi Miio",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_miio",
|
||||
"requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.10"],
|
||||
"requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.11"],
|
||||
"codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"],
|
||||
"zeroconf": ["_miio._udp.local."],
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Z-Wave JS",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zwave_js",
|
||||
"requirements": ["zwave-js-server-python==0.35.1"],
|
||||
"requirements": ["zwave-js-server-python==0.35.2"],
|
||||
"codeowners": ["@home-assistant/z-wave"],
|
||||
"dependencies": ["usb", "http", "websocket_api"],
|
||||
"iot_class": "local_push",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user