Compare commits

...
Sign in to create a new pull request.

62 commits

Author SHA1 Message Date
Josef Zweck
1ce8bfdaa4
Use test helpers for acaia buttons (#130626) 2024-11-14 16:34:17 +01:00
Robert Resch
cd12720085
Add Python version to issue ID (#130611) 2024-11-14 16:31:33 +01:00
epenet
c7ee7dc880
Refactor translation checks (#130585)
* Refactor translation checks

* Adjust

* Improve

* Restore await

* Delay pytest.fail until the end of the test
2024-11-14 16:26:05 +01:00
epenet
472414a8d6
Add missing translation string to smarty (#130624) 2024-11-14 16:17:08 +01:00
Lennard Beers
0c44c632d4
Add number platform to eq3btsmart (#130429) 2024-11-14 15:38:38 +01:00
Álvaro Fernández Rojas
61d0de3042
Bump aioairzone to 0.9.6 (#130559)
* Update aioairzone to v0.9.6

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Remove _async_migrator_mac_empty and improve tests

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Remove WebServer empty mac fixes as requested by @epenet

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

---------

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
2024-11-14 15:27:10 +01:00
Thibaut
01332a542c
Removing myself from template codeowners (#130617)
* Removing myself as codeowners

* Fix

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
2024-11-14 15:23:55 +01:00
Andre Lengwenus
3d84e35268
Move lcn non-config_entry related code to async_setup (#130603)
* Move non-config_entry related code to async_setup

* Remove action unload
2024-11-14 14:27:19 +01:00
Josef Zweck
eea782bbfe
Add acaia integration (#130059)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-14 13:28:38 +01:00
Lennard Beers
a949d18c30
Bump eq3btsmart to 1.4.1 (#130426) 2024-11-14 13:04:22 +01:00
Marc Mueller
a748897bd2
Update hassfest image to Python 3.13 (#130607) 2024-11-14 12:44:06 +01:00
Robert Resch
3201142fd8
Fix hassfest by adding go2rtc reqs (#130602) 2024-11-14 11:01:26 +01:00
starkillerOG
d0a58b68e8
Bump reolink-aio to 0.11.1 (#130600) 2024-11-14 10:48:25 +01:00
Simone Chemelli
93f79be2f4
Update uptime deviation for Vodafone Station (#130571)
Update sensor.py
2024-11-14 10:35:03 +01:00
Robert Resch
46cfe6aa32
Refactor camera WebRTC tests (#130581) 2024-11-14 10:28:04 +01:00
Robert Resch
301043ec38
Add require_webrtc_support decorator (#130519) 2024-11-14 10:27:45 +01:00
puddly
245fc246d8
Ensure ZHA setup works with container installs (#130470) 2024-11-14 10:13:29 +01:00
Noah Husby
58fd917cb7
Disable brightness from devices with no display in Cambridge Audio (#130369) 2024-11-14 10:11:44 +01:00
Steven B.
2c1d1f5777
Do not trigger events for updated ring events (#130430) 2024-11-14 10:09:58 +01:00
Luke Lashley
938b1eca22
Fix when the Roborock map is being provisioned (#130574) 2024-11-14 09:52:28 +01:00
Brett Adams
2fda4c82de
Force login prompt in Tesla Fleet (#130576) 2024-11-14 09:46:24 +01:00
J. Nick Koston
4200913d03
Fix non-thread-safe operation in powerview number (#130557) 2024-11-14 09:45:08 +01:00
Tony
4aad614497
Bump aioruckus to 0.42 (#130487) 2024-11-14 09:43:59 +01:00
epenet
6a3b4a6a23
Adjust minimum scapy version to 2.6.1 (#130565) 2024-11-13 17:49:39 -06:00
Michael Hansen
51c6ee97b1
Upgrade to hassil 2.0 (#130544)
* Working on hassil 2.0

* Bump to hassil 2.0

* Update snapshots

* Remove debug logging
2024-11-13 16:50:08 -05:00
Simon Lamon
4002bc3c25
Downgrade devcontainer to Python 3.12 again (#130562) 2024-11-13 22:03:34 +01:00
J. Nick Koston
c35ef6bda3
Bump aiohttp to 3.11.0 (#130542) 2024-11-13 19:32:14 +01:00
Marc Mueller
ed5560aec2
Update base image to Python 3.13 and deprecated 3.12 (#130425) 2024-11-13 19:28:53 +01:00
Sheldon Ip
0a5a2de78e
Fix translations in subaru (#130486) 2024-11-13 18:46:52 +01:00
Brig Lamoreaux
7fd337d67f
fix translation in srp_energy (#130540) 2024-11-13 18:42:26 +01:00
Marc Mueller
5f68d405b2
Update huum to 0.7.12 (#130527) 2024-11-13 17:26:27 +01:00
Erik Montnemery
093b16c723
Make WS command backup/generate send events (#130524)
* Make WS command backup/generate send events

* Update backup.create service
2024-11-13 16:16:49 +01:00
Steven B.
ac4cb52dbb
Bump ring-doorbell to 0.9.12 (#130419) 2024-11-13 08:04:23 -06:00
dunnmj
72b976f832
Add Sky remote integration (#124507)
Co-authored-by: Kyle Cooke <saty9@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-13 14:29:04 +01:00
Daniel Hjelseth Høyer
f6bc5f050e
Bump millheater to 0.12.2 (#130454) 2024-11-13 14:28:19 +01:00
epenet
8300afc00d
Improve type hints in fritz config flow (#130511)
* Improve type hints in fritz config flow

* Improve coverage

* Apply suggestions from code review

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>

---------

Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
2024-11-13 13:45:52 +01:00
epenet
ab11b84678
Improve type hints in fritzbox config flow (#130509) 2024-11-13 13:01:54 +01:00
Joost Lekkerkerker
b78453b85b
Bump aiowithings to 3.1.3 (#130504) 2024-11-13 12:21:15 +01:00
Joost Lekkerkerker
b270e4556c
Avoid core manifest to have an issue tracker (#130514) 2024-11-13 12:16:07 +01:00
Joost Lekkerkerker
e90893e2bc
Fix Music Assistant manifest (#130515) 2024-11-13 11:43:31 +01:00
dependabot[bot]
a06e7e31b9
Bump github/codeql-action from 3.27.1 to 3.27.3 (#130489)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.27.1 to 3.27.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3.27.1...v3.27.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-13 11:06:38 +01:00
Robert Resch
2eaaadd736
Add go2rtc recommended version (#130508) 2024-11-13 11:01:05 +01:00
G Johansson
0ac00ef092
Fix legacy _attr_state handling in AlarmControlPanel (#130479) 2024-11-13 10:55:28 +01:00
Robert Resch
3092297979
Bump go2rtc-client to 0.1.1 (#130498) 2024-11-13 09:55:52 +01:00
Thomas55555
827875473b
Fix RecursionError in Husqvarna Automower coordinator (#123085)
* reach maximum recursion depth exceeded in tests

* second background task

* Update homeassistant/components/husqvarna_automower/coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/husqvarna_automower/coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* test

* modify test

* tests

* use correct exception

* reset mock

* use recursion_limit

* remove unneeded ticks

* test TimeoutException

* set lower recursionlimit

* remove not that important comment and move the other

* test that we connect and listen successfully

* Simulate hass shutting down

* skip testing against the recursion limit

* Update homeassistant/components/husqvarna_automower/coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* mock

* Remove comment

* Revert "mock"

This reverts commit e8ddaea3d7.

* Move patch to decorator

* Make execution of patched methods predictable

* Parametrize test, make mocked start_listening block

* Apply suggestions from code review

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Erik <erik@montnemery.com>
2024-11-13 09:54:37 +01:00
Joost Lekkerkerker
5cce369ce8
Bump aiowithings to 3.1.2 (#130469) 2024-11-13 07:55:33 +01:00
Joost Lekkerkerker
fdb773c921
Add title to water heater component (#130446) 2024-11-13 07:55:13 +01:00
starkillerOG
8b505a2273
Bump reolink_aio to 0.11.0 (#130481) 2024-11-13 07:35:51 +01:00
Charles Garwood
a9f468509b
Bump zwave-js-server-python to 0.59.1 (#130468) 2024-11-13 07:14:39 +01:00
J. Nick Koston
4ff8b8015c
Bump aiohttp to 3.11.0rc2 (#130484) 2024-11-12 22:07:26 -06:00
mrspouse
5c52e865a0
Correct spelling of BloodGlucoseConcentrationConverter (#130449)
* Correct spelling of BloodGlucoseConcentrationConverter

* Correct spelling of BloodGlucoseConcentrationConverter
2024-11-12 21:48:42 +01:00
Kelvin Dekker
6bfc0cbb0c
Fix typo in file strings (#130465) 2024-11-12 21:33:52 +01:00
G Johansson
388473ecd7
Add diagnostics to Nord Pool (#130461) 2024-11-12 19:55:27 +01:00
G Johansson
285468d85f
Fix translation in statistics (#130455)
* Fix translation in statistics

* Update homeassistant/components/statistics/strings.json
2024-11-12 18:44:32 +01:00
epenet
167025a18c
Simplify modern_forms config flow (#130441)
* Simplify modern_forms config flow

* Rename variable

* Drop CONF_NAME
2024-11-12 18:03:37 +01:00
Joakim Sørensen
ac0c75a598
Add upload capability to the backup integration (#128546)
* Add upload capability to the backup integration

* Limit context switch

* rename

* coverage for http

* Test receiving a backup file

* Update test_manager.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2024-11-12 15:27:53 +01:00
Robert Resch
cb9cc0f801
Go2rtc bump and set ffmpeg logs to debug (#130371) 2024-11-12 11:53:14 +01:00
Lennard Beers
7758d8ba48
Add switch platform to eq3btsmart (#130363) 2024-11-12 11:42:25 +01:00
epenet
7045b776b6
Use report_usage in helpers (#130365) 2024-11-12 09:25:13 +01:00
J. Nick Koston
22aed92461
Bump aiohttp to 3.11.0rc1 (#130320) 2024-11-12 08:29:01 +01:00
LG-ThinQ-Integration
60bf0f6b06
Fix fan's warning TURN_ON, TURN_OFF (#130327)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2024-11-12 08:26:28 +01:00
G Johansson
3eab72b2aa
Improve exception handling in Nord Pool (#130386)
* Improve exception handling in Nord Pool

* Improve auth string

* Remove auth
2024-11-11 23:02:48 +01:00
145 changed files with 3600 additions and 972 deletions

View file

@ -10,7 +10,7 @@ on:
env: env:
BUILD_TYPE: core BUILD_TYPE: core
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60 PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true" UV_SYSTEM_PYTHON: "true"

View file

@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.27.1 uses: github/codeql-action/init@v3.27.3
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.27.1 uses: github/codeql-action/analyze@v3.27.3
with: with:
category: "/language:python" category: "/language:python"

View file

@ -90,7 +90,7 @@ repos:
pass_filenames: false pass_filenames: false
language: script language: script
types: [text] types: [text]
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$ files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$
- id: hassfest-mypy-config - id: hassfest-mypy-config
name: hassfest-mypy-config name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config

View file

@ -40,6 +40,8 @@ build.json @home-assistant/supervisor
# Integrations # Integrations
/homeassistant/components/abode/ @shred86 /homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86 /tests/components/abode/ @shred86
/homeassistant/components/acaia/ @zweckj
/tests/components/acaia/ @zweckj
/homeassistant/components/accuweather/ @bieniu /homeassistant/components/accuweather/ @bieniu
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
@ -1344,6 +1346,8 @@ build.json @home-assistant/supervisor
/tests/components/siren/ @home-assistant/core @raman325 /tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sisyphus/ @jkeljo
/homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/sky_remote/ @dunnmj @saty9
/tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob /homeassistant/components/skybell/ @tkdrob
/tests/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau /homeassistant/components/slack/ @tkdrob @fletcherau
@ -1485,8 +1489,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike /homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/template/ @PhracturedBlue @home-assistant/core
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /tests/components/template/ @PhracturedBlue @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks /homeassistant/components/tesla_wall_connector/ @einarhauks

View file

@ -55,7 +55,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \ "armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \ esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \ && chmod +x /bin/go2rtc \
# Verify go2rtc can be executed # Verify go2rtc can be executed
&& go2rtc --version && go2rtc --version

View file

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View file

@ -515,7 +515,7 @@ async def async_from_config_dict(
issue_registry.async_create_issue( issue_registry.async_create_issue(
hass, hass,
core.DOMAIN, core.DOMAIN,
"python_version", f"python_version_{required_python_version}",
is_fixable=False, is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING, severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE, breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,

View file

@ -0,0 +1,5 @@
{
"domain": "sky",
"name": "Sky",
"integrations": ["sky_hub", "sky_remote"]
}

View file

@ -0,0 +1,29 @@
"""Initialize the Acaia component."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
PLATFORMS = [
Platform.BUTTON,
]
async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Set up acaia as config entry."""
coordinator = AcaiaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,61 @@
"""Button entities for Acaia scales."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaButtonEntityDescription(ButtonEntityDescription):
"""Description for acaia button entities."""
press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]]
BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
AcaiaButtonEntityDescription(
key="tare",
translation_key="tare",
press_fn=lambda scale: scale.tare(),
),
AcaiaButtonEntityDescription(
key="reset_timer",
translation_key="reset_timer",
press_fn=lambda scale: scale.reset_timer(),
),
AcaiaButtonEntityDescription(
key="start_stop",
translation_key="start_stop",
press_fn=lambda scale: scale.start_stop_timer(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities and services."""
coordinator = entry.runtime_data
async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS)
class AcaiaButton(AcaiaEntity, ButtonEntity):
"""Representation of an Acaia button."""
entity_description: AcaiaButtonEntityDescription
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self._scale)

View file

@ -0,0 +1,149 @@
"""Config flow for Acaia integration."""
import logging
from typing import Any
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for acaia."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] = {}
self._discovered_devices: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
mac = format_mac(user_input[CONF_ADDRESS])
try:
is_new_style_scale = await is_new_scale(mac)
except AcaiaDeviceNotFound:
errors["base"] = "device_not_found"
except AcaiaError:
_LOGGER.exception("Error occurred while connecting to the scale")
errors["base"] = "unknown"
except AcaiaUnknownDevice:
return self.async_abort(reason="unsupported_device")
else:
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=self._discovered_devices[user_input[CONF_ADDRESS]],
data={
CONF_ADDRESS: mac,
CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
},
)
for device in async_discovered_service_info(self.hass):
self._discovered_devices[device.address] = device.name
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
options = [
SelectOptionDict(
value=device_mac,
label=f"{device_name} ({device_mac})",
)
for device_mac, device_name in self._discovered_devices.items()
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a discovered Bluetooth device."""
self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address)
self._discovered[CONF_NAME] = discovery_info.name
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
try:
self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale(
discovery_info.address
)
except AcaiaDeviceNotFound:
_LOGGER.debug("Device not found during discovery")
return self.async_abort(reason="device_not_found")
except AcaiaError:
_LOGGER.debug(
"Error occurred while connecting to the scale during discovery",
exc_info=True,
)
return self.async_abort(reason="unknown")
except AcaiaUnknownDevice:
_LOGGER.debug("Unsupported device during discovery")
return self.async_abort(reason="unsupported_device")
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle confirmation of Bluetooth discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered[CONF_NAME],
data={
CONF_ADDRESS: self._discovered[CONF_ADDRESS],
CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE],
},
)
self.context["title_placeholders"] = placeholders = {
CONF_NAME: self._discovered[CONF_NAME]
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=placeholders,
)

View file

@ -0,0 +1,4 @@
"""Constants for component."""
DOMAIN = "acaia"
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"

View file

@ -0,0 +1,86 @@
"""Coordinator for Acaia integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15)
_LOGGER = logging.getLogger(__name__)
type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator]
class AcaiaCoordinator(DataUpdateCoordinator[None]):
"""Class to handle fetching data from the scale."""
config_entry: AcaiaConfigEntry
def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="acaia coordinator",
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=self.async_update_listeners,
)
@property
def scale(self) -> AcaiaScale:
"""Return the scale object."""
return self._scale
async def _async_update_data(self) -> None:
"""Fetch data."""
# scale is already connected, return
if self._scale.connected:
return
# scale is not connected, try to connect
try:
await self._scale.connect(setup_tasks=False)
except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex:
_LOGGER.debug(
"Could not connect to scale: %s, Error: %s",
self.config_entry.data[CONF_ADDRESS],
ex,
)
self._scale.device_disconnected_handler(notify=False)
return
# connected, set up background tasks
if not self._scale.heartbeat_task or self._scale.heartbeat_task.done():
self._scale.heartbeat_task = self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.send_heartbeats(),
name="acaia_heartbeat_task",
)
if not self._scale.process_queue_task or self._scale.process_queue_task.done():
self._scale.process_queue_task = (
self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.process_queue(),
name="acaia_process_queue_task",
)
)

View file

@ -0,0 +1,40 @@
"""Base class for Acaia entities."""
from dataclasses import dataclass
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AcaiaCoordinator
@dataclass
class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AcaiaCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._scale = coordinator.scale
self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._scale.mac)},
manufacturer="Acaia",
model=self._scale.model,
suggested_area="Kitchen",
)
@property
def available(self) -> bool:
"""Returns whether entity is available."""
return super().available and self._scale.connected

View file

@ -0,0 +1,15 @@
{
"entity": {
"button": {
"tare": {
"default": "mdi:scale-balance"
},
"reset_timer": {
"default": "mdi:timer-refresh"
},
"start_stop": {
"default": "mdi:timer-play"
}
}
}
}

View file

@ -0,0 +1,29 @@
{
"domain": "acaia",
"name": "Acaia",
"bluetooth": [
{
"manufacturer_id": 16962
},
{
"local_name": "ACAIA*"
},
{
"local_name": "PYXIS-*"
},
{
"local_name": "LUNAR-*"
},
{
"local_name": "PROCHBT001"
}
],
"codeowners": ["@zweckj"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/acaia",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioacaia"],
"requirements": ["aioacaia==0.1.6"]
}

View file

@ -0,0 +1,38 @@
{
"config": {
"flow_title": "{name}",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unsupported_device": "This device is not supported."
},
"error": {
"device_not_found": "Device could not be found.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
}
},
"entity": {
"button": {
"tare": {
"name": "Tare"
},
"reset_timer": {
"name": "Reset timer"
},
"start_stop": {
"name": "Start/stop timer"
}
}
}
}

View file

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.5"] "requirements": ["aioairzone==0.9.6"]
} }

View file

@ -6,7 +6,7 @@ import asyncio
from datetime import timedelta from datetime import timedelta
from functools import partial from functools import partial
import logging import logging
from typing import Any, Final, final from typing import TYPE_CHECKING, Any, Final, final
from propcache import cached_property from propcache import cached_property
import voluptuous as vol import voluptuous as vol
@ -221,9 +221,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
@property @property
def state(self) -> str | None: def state(self) -> str | None:
"""Return the current state.""" """Return the current state."""
if (alarm_state := self.alarm_state) is None: if (alarm_state := self.alarm_state) is not None:
return None return alarm_state
return alarm_state if self._attr_state is not None:
# Backwards compatibility for integrations that set state directly
# Should be removed in 2025.11
if TYPE_CHECKING:
assert isinstance(self._attr_state, str)
return self._attr_state
return None
@cached_property @cached_property
def alarm_state(self) -> AlarmControlPanelState | None: def alarm_state(self) -> AlarmControlPanelState | None:

View file

@ -32,7 +32,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_handle_create_service(call: ServiceCall) -> None: async def async_handle_create_service(call: ServiceCall) -> None:
"""Service handler for creating backups.""" """Service handler for creating backups."""
await backup_manager.async_create_backup() await backup_manager.async_create_backup(on_progress=None)
if backup_task := backup_manager.backup_task:
await backup_task
hass.services.async_register(DOMAIN, "create", async_handle_create_service) hass.services.async_register(DOMAIN, "create", async_handle_create_service)

View file

@ -2,23 +2,26 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response from aiohttp.web import FileResponse, Request, Response
from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify from homeassistant.util import slugify
from .const import DOMAIN from .const import DATA_MANAGER
from .manager import BaseBackupManager
@callback @callback
def async_register_http_views(hass: HomeAssistant) -> None: def async_register_http_views(hass: HomeAssistant) -> None:
"""Register the http views.""" """Register the http views."""
hass.http.register_view(DownloadBackupView) hass.http.register_view(DownloadBackupView)
hass.http.register_view(UploadBackupView)
class DownloadBackupView(HomeAssistantView): class DownloadBackupView(HomeAssistantView):
@ -36,7 +39,7 @@ class DownloadBackupView(HomeAssistantView):
if not request["hass_user"].is_admin: if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED) return Response(status=HTTPStatus.UNAUTHORIZED)
manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN] manager = request.app[KEY_HASS].data[DATA_MANAGER]
backup = await manager.async_get_backup(slug=slug) backup = await manager.async_get_backup(slug=slug)
if backup is None or not backup.path.exists(): if backup is None or not backup.path.exists():
@ -48,3 +51,29 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}, },
) )
class UploadBackupView(HomeAssistantView):
"""Generate backup view."""
url = "/api/backup/upload"
name = "api:backup:upload"
@require_admin
async def post(self, request: Request) -> Response:
"""Upload a backup file."""
manager = request.app[KEY_HASS].data[DATA_MANAGER]
reader = await request.multipart()
contents = cast(BodyPartReader, await reader.next())
try:
await manager.async_receive_backup(contents=contents)
except OSError as err:
return Response(
body=f"Can't write backup file {err}",
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
except asyncio.CancelledError:
return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
return Response(status=HTTPStatus.CREATED)

View file

@ -4,16 +4,21 @@ from __future__ import annotations
import abc import abc
import asyncio import asyncio
from collections.abc import Callable
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import hashlib import hashlib
import io import io
import json import json
from pathlib import Path from pathlib import Path
from queue import SimpleQueue
import shutil
import tarfile import tarfile
from tarfile import TarError from tarfile import TarError
from tempfile import TemporaryDirectory
import time import time
from typing import Any, Protocol, cast from typing import Any, Protocol, cast
import aiohttp
from securetar import SecureTarFile, atomic_contents_add from securetar import SecureTarFile, atomic_contents_add
from homeassistant.backup_restore import RESTORE_BACKUP_FILE from homeassistant.backup_restore import RESTORE_BACKUP_FILE
@ -30,6 +35,13 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
BUF_SIZE = 2**20 * 4 # 4MB BUF_SIZE = 2**20 * 4 # 4MB
@dataclass(slots=True)
class NewBackup:
"""New backup class."""
slug: str
@dataclass(slots=True) @dataclass(slots=True)
class Backup: class Backup:
"""Backup class.""" """Backup class."""
@ -45,6 +57,15 @@ class Backup:
return {**asdict(self), "path": self.path.as_posix()} return {**asdict(self), "path": self.path.as_posix()}
@dataclass(slots=True)
class BackupProgress:
"""Backup progress class."""
done: bool
stage: str | None
success: bool | None
class BackupPlatformProtocol(Protocol): class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have.""" """Define the format that backup platforms can have."""
@ -61,7 +82,7 @@ class BaseBackupManager(abc.ABC):
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager.""" """Initialize the backup manager."""
self.hass = hass self.hass = hass
self.backing_up = False self.backup_task: asyncio.Task | None = None
self.backups: dict[str, Backup] = {} self.backups: dict[str, Backup] = {}
self.loaded_platforms = False self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {} self.platforms: dict[str, BackupPlatformProtocol] = {}
@ -129,7 +150,12 @@ class BaseBackupManager(abc.ABC):
"""Restore a backup.""" """Restore a backup."""
@abc.abstractmethod @abc.abstractmethod
async def async_create_backup(self, **kwargs: Any) -> Backup: async def async_create_backup(
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup.""" """Generate a backup."""
@abc.abstractmethod @abc.abstractmethod
@ -147,6 +173,15 @@ class BaseBackupManager(abc.ABC):
async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Remove a backup.""" """Remove a backup."""
@abc.abstractmethod
async def async_receive_backup(
self,
*,
contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
class BackupManager(BaseBackupManager): class BackupManager(BaseBackupManager):
"""Backup manager for the Backup integration.""" """Backup manager for the Backup integration."""
@ -222,17 +257,93 @@ class BackupManager(BaseBackupManager):
LOGGER.debug("Removed backup located at %s", backup.path) LOGGER.debug("Removed backup located at %s", backup.path)
self.backups.pop(slug) self.backups.pop(slug)
async def async_create_backup(self, **kwargs: Any) -> Backup: async def async_receive_backup(
"""Generate a backup.""" self,
if self.backing_up: *,
raise HomeAssistantError("Backup already in progress") contents: aiohttp.BodyPartReader,
**kwargs: Any,
) -> None:
"""Receive and store a backup file from upload."""
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
SimpleQueue()
)
temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory)
target_temp_file = Path(
temp_dir_handler.name, contents.filename or "backup.tar"
)
def _sync_queue_consumer() -> None:
with target_temp_file.open("wb") as file_handle:
while True:
if (_chunk_future := queue.get()) is None:
break
_chunk, _future = _chunk_future
if _future is not None:
self.hass.loop.call_soon_threadsafe(_future.set_result, None)
file_handle.write(_chunk)
fut: asyncio.Future[None] | None = None
try:
fut = self.hass.async_add_executor_job(_sync_queue_consumer)
megabytes_sending = 0
while chunk := await contents.read_chunk(BUF_SIZE):
megabytes_sending += 1
if megabytes_sending % 5 != 0:
queue.put_nowait((chunk, None))
continue
chunk_future = self.hass.loop.create_future()
queue.put_nowait((chunk, chunk_future))
await asyncio.wait(
(fut, chunk_future),
return_when=asyncio.FIRST_COMPLETED,
)
if fut.done():
# The executor job failed
break
queue.put_nowait(None) # terminate queue consumer
finally:
if fut is not None:
await fut
def _move_and_cleanup() -> None:
shutil.move(target_temp_file, self.backup_dir / target_temp_file.name)
temp_dir_handler.cleanup()
await self.hass.async_add_executor_job(_move_and_cleanup)
await self.load_backups()
async def async_create_backup(
self,
*,
on_progress: Callable[[BackupProgress], None] | None,
**kwargs: Any,
) -> NewBackup:
"""Generate a backup."""
if self.backup_task:
raise HomeAssistantError("Backup already in progress")
backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name)
self.backup_task = self.hass.async_create_task(
self._async_create_backup(backup_name, date_str, slug, on_progress),
name="backup_manager_create_backup",
eager_start=False, # To ensure the task is not started before we return
)
return NewBackup(slug=slug)
async def _async_create_backup(
self,
backup_name: str,
date_str: str,
slug: str,
on_progress: Callable[[BackupProgress], None] | None,
) -> Backup:
"""Generate a backup."""
success = False
try: try:
self.backing_up = True
await self.async_pre_backup_actions() await self.async_pre_backup_actions()
backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name)
backup_data = { backup_data = {
"slug": slug, "slug": slug,
@ -259,9 +370,12 @@ class BackupManager(BaseBackupManager):
if self.loaded_backups: if self.loaded_backups:
self.backups[slug] = backup self.backups[slug] = backup
LOGGER.debug("Generated new backup with slug %s", slug) LOGGER.debug("Generated new backup with slug %s", slug)
success = True
return backup return backup
finally: finally:
self.backing_up = False if on_progress:
on_progress(BackupProgress(done=True, stage=None, success=success))
self.backup_task = None
await self.async_post_backup_actions() await self.async_post_backup_actions()
def _mkdir_and_generate_backup_contents( def _mkdir_and_generate_backup_contents(

View file

@ -8,6 +8,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .const import DATA_MANAGER, LOGGER from .const import DATA_MANAGER, LOGGER
from .manager import BackupProgress
@callback @callback
@ -40,7 +41,7 @@ async def handle_info(
msg["id"], msg["id"],
{ {
"backups": list(backups.values()), "backups": list(backups.values()),
"backing_up": manager.backing_up, "backing_up": manager.backup_task is not None,
}, },
) )
@ -113,7 +114,11 @@ async def handle_create(
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Generate a backup.""" """Generate a backup."""
backup = await hass.data[DATA_MANAGER].async_create_backup()
def on_progress(progress: BackupProgress) -> None:
connection.send_message(websocket_api.event_message(msg["id"], progress))
backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress)
connection.send_result(msg["id"], backup) connection.send_result(msg["id"], backup)
@ -127,7 +132,6 @@ async def handle_backup_start(
) -> None: ) -> None:
"""Backup start notification.""" """Backup start notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = True
LOGGER.debug("Backup start notification") LOGGER.debug("Backup start notification")
try: try:
@ -149,7 +153,6 @@ async def handle_backup_end(
) -> None: ) -> None:
"""Backup end notification.""" """Backup end notification."""
manager = hass.data[DATA_MANAGER] manager = hass.data[DATA_MANAGER]
manager.backing_up = False
LOGGER.debug("Backup end notification") LOGGER.debug("Backup end notification")
try: try:

View file

@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiostreammagic"], "loggers": ["aiostreammagic"],
"requirements": ["aiostreammagic==2.8.4"], "requirements": ["aiostreammagic==2.8.5"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
} }

View file

@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription( CambridgeAudioSelectEntityDescription(
key="display_brightness", key="display_brightness",
translation_key="display_brightness", translation_key="display_brightness",
options=[x.value for x in DisplayBrightness], options=[
DisplayBrightness.BRIGHT.value,
DisplayBrightness.DIM.value,
DisplayBrightness.OFF.value,
],
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness, value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness( set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value) DisplayBrightness(value)

View file

@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from functools import cache, partial from functools import cache, partial, wraps
import logging import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
@ -205,6 +205,49 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
) )
type WsCommandWithCamera = Callable[
[websocket_api.ActiveConnection, dict[str, Any], Camera],
Awaitable[None],
]
def require_webrtc_support(
error_code: str,
) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]:
"""Validate that the camera supports WebRTC."""
def decorate(
func: WsCommandWithCamera,
) -> websocket_api.AsyncWebSocketCommandHandler:
"""Decorate func."""
@wraps(func)
async def validate(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Validate that the camera supports WebRTC."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
error_code,
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await func(connection, msg, camera)
return validate
return decorate
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "camera/webrtc/offer", vol.Required("type"): "camera/webrtc/offer",
@ -213,8 +256,9 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_offer_failed")
async def ws_webrtc_offer( async def ws_webrtc_offer(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None: ) -> None:
"""Handle the signal path for a WebRTC stream. """Handle the signal path for a WebRTC stream.
@ -226,20 +270,7 @@ async def ws_webrtc_offer(
Async friendly. Async friendly.
""" """
entity_id = msg["entity_id"]
offer = msg["offer"] offer = msg["offer"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_offer_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
session_id = ulid() session_id = ulid()
connection.subscriptions[msg["id"]] = partial( connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id camera.close_webrtc_session, session_id
@ -278,23 +309,11 @@ async def ws_webrtc_offer(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_get_client_config_failed")
async def ws_get_client_config( async def ws_get_client_config(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None: ) -> None:
"""Handle get WebRTC client config websocket command.""" """Handle get WebRTC client config websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_get_client_config_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
config = camera.async_get_webrtc_client_configuration().to_frontend_dict() config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
connection.send_result( connection.send_result(
msg["id"], msg["id"],
@ -311,23 +330,11 @@ async def ws_get_client_config(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_candidate_failed")
async def ws_candidate( async def ws_candidate(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None: ) -> None:
"""Handle WebRTC candidate websocket command.""" """Handle WebRTC candidate websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_candidate_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await camera.async_on_webrtc_candidate( await camera.async_on_webrtc_candidate(
msg["session_id"], RTCIceCandidate(msg["candidate"]) msg["session_id"], RTCIceCandidate(msg["candidate"])
) )

View file

@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
from hassil.recognize import ( from hassil.recognize import (
MISSING_ENTITY, MISSING_ENTITY,
MatchEntity,
RecognizeResult, RecognizeResult,
UnmatchedTextEntity,
recognize_all, recognize_all,
recognize_best,
) )
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.util import merge_dict from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml import yaml
@ -499,6 +499,7 @@ class DefaultAgent(ConversationEntity):
maybe_result: RecognizeResult | None = None maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0 best_num_matched_entities = 0
best_num_unmatched_entities = 0 best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all( for result in recognize_all(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
@ -517,10 +518,14 @@ class DefaultAgent(ConversationEntity):
num_matched_entities += 1 num_matched_entities += 1
num_unmatched_entities = 0 num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list: for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity): if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY: if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1 num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else: else:
num_unmatched_entities += 1 num_unmatched_entities += 1
@ -532,15 +537,24 @@ class DefaultAgent(ConversationEntity):
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities) and (num_unmatched_entities < best_num_unmatched_entities)
) )
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or ( or (
# More literal text matched # More literal text matched
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities) and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (result.text_chunks_matched > maybe_result.text_chunks_matched) and (result.text_chunks_matched > maybe_result.text_chunks_matched)
) )
or ( or (
# Prefer match failures with entities # Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched) (result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and ( and (
("name" in result.entities) ("name" in result.entities)
or ("name" in result.unmatched_entities) or ("name" in result.unmatched_entities)
@ -550,6 +564,7 @@ class DefaultAgent(ConversationEntity):
maybe_result = result maybe_result = result
best_num_matched_entities = num_matched_entities best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result return maybe_result
@ -562,76 +577,15 @@ class DefaultAgent(ConversationEntity):
language: str, language: str,
) -> RecognizeResult | None: ) -> RecognizeResult | None:
"""Search intents for a strict match to user input.""" """Search intents for a strict match to user input."""
custom_found = False return recognize_best(
name_found = False
best_results: list[RecognizeResult] = []
best_name_quality: int | None = None
best_text_chunks_matched: int | None = None
for result in recognize_all(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
slot_lists=slot_lists, slot_lists=slot_lists,
intent_context=intent_context, intent_context=intent_context,
language=language, language=language,
): best_metadata_key=METADATA_CUSTOM_SENTENCE,
# Prioritize user intents best_slot_name="name",
is_custom = ( )
result.intent_metadata is not None
and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE)
)
if custom_found and not is_custom:
continue
if not custom_found and is_custom:
custom_found = True
# Clear builtin results
name_found = False
best_results = []
best_name_quality = None
best_text_chunks_matched = None
# Prioritize results with a "name" slot
name = result.entities.get("name")
is_name = name and not name.is_wildcard
if name_found and not is_name:
continue
if not name_found and is_name:
name_found = True
# Clear non-name results
best_results = []
best_text_chunks_matched = None
if is_name:
# Prioritize results with a better "name" slot
name_quality = len(cast(MatchEntity, name).value.split())
if (best_name_quality is None) or (name_quality > best_name_quality):
best_name_quality = name_quality
# Clear worse name results
best_results = []
best_text_chunks_matched = None
elif name_quality < best_name_quality:
continue
# Prioritize results with more literal text
# This causes wildcards to match last.
if (best_text_chunks_matched is None) or (
result.text_chunks_matched > best_text_chunks_matched
):
best_results = [result]
best_text_chunks_matched = result.text_chunks_matched
elif result.text_chunks_matched == best_text_chunks_matched:
# Accumulate results with the same number of literal text matched.
# We will resolve the ambiguity below.
best_results.append(result)
if best_results:
# Successful strict match
return best_results[0]
return None
async def _build_speech( async def _build_speech(
self, self,

View file

@ -6,12 +6,8 @@ from collections.abc import Iterable
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from hassil.recognize import ( from hassil.recognize import MISSING_ENTITY, RecognizeResult
MISSING_ENTITY, from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
RecognizeResult,
UnmatchedRangeEntity,
UnmatchedTextEntity,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components import http, websocket_api from homeassistant.components import http, websocket_api

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"] "requirements": ["hassil==2.0.1", "home-assistant-intents==2024.11.13"]
} }

View file

@ -4,7 +4,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from hassil.recognize import PUNCTUATION, RecognizeResult from hassil.recognize import RecognizeResult
from hassil.util import PUNCTUATION_ALL
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
@ -20,7 +21,7 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN
def has_no_punctuation(value: list[str]) -> list[str]: def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation.""" """Validate result does not contain punctuation."""
for sentence in value: for sentence in value:
if PUNCTUATION.search(sentence): if PUNCTUATION_ALL.search(sentence):
raise vol.Invalid("sentence should not contain punctuation") raise vol.Invalid("sentence should not contain punctuation")
return value return value

View file

@ -21,6 +21,8 @@ from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.NUMBER,
Platform.SWITCH,
] ]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View file

@ -21,6 +21,14 @@ DEVICE_MODEL = "CC-RT-BLE-EQ"
ENTITY_KEY_DST = "dst" ENTITY_KEY_DST = "dst"
ENTITY_KEY_BATTERY = "battery" ENTITY_KEY_BATTERY = "battery"
ENTITY_KEY_WINDOW = "window" ENTITY_KEY_WINDOW = "window"
ENTITY_KEY_LOCK = "lock"
ENTITY_KEY_BOOST = "boost"
ENTITY_KEY_AWAY = "away"
ENTITY_KEY_COMFORT = "comfort"
ENTITY_KEY_ECO = "eco"
ENTITY_KEY_OFFSET = "offset"
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature"
ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout"
GET_DEVICE_TIMEOUT = 5 # seconds GET_DEVICE_TIMEOUT = 5 # seconds
@ -74,3 +82,5 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
EQ3BT_STEP = 0.5

View file

@ -0,0 +1,49 @@
{
"entity": {
"binary_sensor": {
"dst": {
"default": "mdi:sun-clock",
"state": {
"off": "mdi:sun-clock-outline"
}
}
},
"number": {
"comfort": {
"default": "mdi:sun-thermometer"
},
"eco": {
"default": "mdi:snowflake-thermometer"
},
"offset": {
"default": "mdi:thermometer-plus"
},
"window_open_temperature": {
"default": "mdi:window-open-variant"
},
"window_open_timeout": {
"default": "mdi:timer-refresh"
}
},
"switch": {
"away": {
"default": "mdi:home-account",
"state": {
"on": "mdi:home-export"
}
},
"lock": {
"default": "mdi:lock",
"state": {
"off": "mdi:lock-off"
}
},
"boost": {
"default": "mdi:fire",
"state": {
"off": "mdi:fire-off"
}
}
}
}
}

View file

@ -23,5 +23,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "loggers": ["eq3btsmart"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["eq3btsmart==1.2.1", "bleak-esphome==1.1.0"] "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"]
} }

View file

@ -2,7 +2,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP
from eq3btsmart.thermostat import Thermostat from eq3btsmart.thermostat import Thermostat
from .const import ( from .const import (
@ -23,8 +22,6 @@ class Eq3Config:
target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR
external_temp_sensor: str = "" external_temp_sensor: str = ""
scan_interval: int = DEFAULT_SCAN_INTERVAL scan_interval: int = DEFAULT_SCAN_INTERVAL
default_away_hours: float = DEFAULT_AWAY_HOURS
default_away_temperature: float = DEFAULT_AWAY_TEMP
@dataclass(slots=True) @dataclass(slots=True)

View file

@ -0,0 +1,158 @@
"""Platform for eq3 number entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart import Thermostat
from eq3btsmart.const import (
EQ3BT_MAX_OFFSET,
EQ3BT_MAX_TEMP,
EQ3BT_MIN_OFFSET,
EQ3BT_MIN_TEMP,
)
from eq3btsmart.models import Presets
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import (
ENTITY_KEY_COMFORT,
ENTITY_KEY_ECO,
ENTITY_KEY_OFFSET,
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
EQ3BT_STEP,
)
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3NumberEntityDescription(NumberEntityDescription):
"""Entity description for eq3 number entities."""
value_func: Callable[[Presets], float]
value_set_func: Callable[
[Thermostat],
Callable[[float], Awaitable[None]],
]
mode: NumberMode = NumberMode.BOX
entity_category: EntityCategory | None = EntityCategory.CONFIG
NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription(
key=ENTITY_KEY_COMFORT,
value_func=lambda presets: presets.comfort_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
translation_key=ENTITY_KEY_COMFORT,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_ECO,
value_func=lambda presets: presets.eco_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
translation_key=ENTITY_KEY_ECO,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
value_func=lambda presets: presets.window_open_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_OFFSET,
value_func=lambda presets: presets.offset_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
translation_key=ENTITY_KEY_OFFSET,
native_min_value=EQ3BT_MIN_OFFSET,
native_max_value=EQ3BT_MAX_OFFSET,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
native_min_value=0,
native_max_value=60,
native_step=5,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3NumberEntity(entry, entity_description)
for entity_description in NUMBER_ENTITY_DESCRIPTIONS
)
class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Base class for all eq3 number entities."""
entity_description: Eq3NumberEntityDescription
def __init__(
self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def native_value(self) -> float:
"""Return the state of the entity."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
assert self._thermostat.status.presets is not None
return self.entity_description.value_func(self._thermostat.status.presets)
async def async_set_native_value(self, value: float) -> None:
"""Set the state of the entity."""
await self.entity_description.value_set_func(self._thermostat)(value)
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return (
self._thermostat.status is not None
and self._thermostat.status.presets is not None
and self._attr_available
)

View file

@ -24,6 +24,34 @@
"dst": { "dst": {
"name": "Daylight saving time" "name": "Daylight saving time"
} }
},
"number": {
"comfort": {
"name": "Comfort temperature"
},
"eco": {
"name": "Eco temperature"
},
"offset": {
"name": "Offset temperature"
},
"window_open_temperature": {
"name": "Window open temperature"
},
"window_open_timeout": {
"name": "Window open timeout"
}
},
"switch": {
"lock": {
"name": "Lock"
},
"boost": {
"name": "Boost"
},
"away": {
"name": "Away"
}
} }
} }
} }

View file

@ -0,0 +1,94 @@
"""Platform for eq3 switch entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from eq3btsmart import Thermostat
from eq3btsmart.models import Status
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3SwitchEntityDescription(SwitchEntityDescription):
"""Entity description for eq3 switch entities."""
toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]]
value_func: Callable[[Status], bool]
SWITCH_ENTITY_DESCRIPTIONS = [
Eq3SwitchEntityDescription(
key=ENTITY_KEY_LOCK,
translation_key=ENTITY_KEY_LOCK,
toggle_func=lambda thermostat: thermostat.async_set_locked,
value_func=lambda status: status.is_locked,
),
Eq3SwitchEntityDescription(
key=ENTITY_KEY_BOOST,
translation_key=ENTITY_KEY_BOOST,
toggle_func=lambda thermostat: thermostat.async_set_boost,
value_func=lambda status: status.is_boost,
),
Eq3SwitchEntityDescription(
key=ENTITY_KEY_AWAY,
translation_key=ENTITY_KEY_AWAY,
toggle_func=lambda thermostat: thermostat.async_set_away,
value_func=lambda status: status.is_away,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3SwitchEntity(entry, entity_description)
for entity_description in SWITCH_ENTITY_DESCRIPTIONS
)
class Eq3SwitchEntity(Eq3Entity, SwitchEntity):
"""Base class for eq3 switch entities."""
entity_description: Eq3SwitchEntityDescription
def __init__(
self,
entry: Eq3ConfigEntry,
entity_description: Eq3SwitchEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
await self.entity_description.toggle_func(self._thermostat)(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
await self.entity_description.toggle_func(self._thermostat)(False)
@property
def is_on(self) -> bool:
"""Return the state of the switch."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
return self.entity_description.value_func(self._thermostat.status)

View file

@ -18,7 +18,7 @@
}, },
"data_description": { "data_description": {
"file_path": "The local file path to retrieve the sensor value from", "file_path": "The local file path to retrieve the sensor value from",
"value_template": "A template to render the the sensors value based on the file content", "value_template": "A template to render the sensors value based on the file content",
"unit_of_measurement": "Unit of measurement for the sensor" "unit_of_measurement": "Unit of measurement for the sensor"
} }
}, },

View file

@ -57,6 +57,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
_host: str
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@ -67,7 +69,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize FRITZ!Box Tools flow.""" """Initialize FRITZ!Box Tools flow."""
self._host: str | None = None
self._name: str = "" self._name: str = ""
self._password: str = "" self._password: str = ""
self._use_tls: bool = False self._use_tls: bool = False
@ -112,7 +113,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_check_configured_entry(self) -> ConfigEntry | None: async def async_check_configured_entry(self) -> ConfigEntry | None:
"""Check if entry is configured.""" """Check if entry is configured."""
assert self._host
current_host = await self.hass.async_add_executor_job( current_host = await self.hass.async_add_executor_job(
socket.gethostbyname, self._host socket.gethostbyname, self._host
) )
@ -154,15 +154,17 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initialized by discovery.""" """Handle a flow initialized by discovery."""
ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "")
self._host = ssdp_location.hostname host = ssdp_location.hostname
if not host or ipaddress.ip_address(host).is_link_local:
return self.async_abort(reason="ignore_ip6_link_local")
self._host = host
self._name = ( self._name = (
discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME)
or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME]
) )
if not self._host or ipaddress.ip_address(self._host).is_link_local: uuid: str | None
return self.async_abort(reason="ignore_ip6_link_local")
if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN):
if uuid.startswith("uuid:"): if uuid.startswith("uuid:"):
uuid = uuid[5:] uuid = uuid[5:]

View file

@ -43,10 +43,11 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
_name: str
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize flow.""" """Initialize flow."""
self._host: str | None = None self._host: str | None = None
self._name: str | None = None
self._password: str | None = None self._password: str | None = None
self._username: str | None = None self._username: str | None = None
@ -158,7 +159,6 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN):
result = await self.async_try_connect() result = await self.async_try_connect()
if result == RESULT_SUCCESS: if result == RESULT_SUCCESS:
assert self._name is not None
return self._get_entry(self._name) return self._get_entry(self._name)
if result != RESULT_INVALID_AUTH: if result != RESULT_INVALID_AUTH:
return self.async_abort(reason=result) return self.async_abort(reason=result)

View file

@ -1,12 +1,10 @@
"""The go2rtc component.""" """The go2rtc component."""
from __future__ import annotations
from dataclasses import dataclass
import logging import logging
import shutil import shutil
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
from awesomeversion import AwesomeVersion
from go2rtc_client import Go2RtcRestClient from go2rtc_client import Go2RtcRestClient
from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError
from go2rtc_client.ws import ( from go2rtc_client.ws import (
@ -35,7 +33,11 @@ from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers import (
config_validation as cv,
discovery_flow,
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
@ -45,8 +47,8 @@ from .const import (
CONF_DEBUG_UI, CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE, DEBUG_UI_URL_MESSAGE,
DOMAIN, DOMAIN,
HA_MANAGED_RTSP_PORT,
HA_MANAGED_URL, HA_MANAGED_URL,
RECOMMENDED_VERSION,
) )
from .server import Server from .server import Server
@ -94,22 +96,13 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN) _DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
@dataclass(frozen=True)
class Go2RtcData:
"""Data for go2rtc."""
url: str
managed: bool
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WebRTC.""" """Set up WebRTC."""
url: str | None = None url: str | None = None
managed = False
if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config:
await _remove_go2rtc_entries(hass) await _remove_go2rtc_entries(hass)
return True return True
@ -144,9 +137,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
url = HA_MANAGED_URL url = HA_MANAGED_URL
managed = True
hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed) hass.data[_DATA_GO2RTC] = url
discovery_flow.async_create_flow( discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
) )
@ -161,32 +153,42 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up go2rtc from a config entry.""" """Set up go2rtc from a config entry."""
data = hass.data[_DATA_GO2RTC] url = hass.data[_DATA_GO2RTC]
# Validate the server URL # Validate the server URL
try: try:
client = Go2RtcRestClient(async_get_clientsession(hass), data.url) client = Go2RtcRestClient(async_get_clientsession(hass), url)
await client.validate_server_version() version = await client.validate_server_version()
if version < AwesomeVersion(RECOMMENDED_VERSION):
ir.async_create_issue(
hass,
DOMAIN,
"recommended_version",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="recommended_version",
translation_placeholders={
"recommended_version": RECOMMENDED_VERSION,
"current_version": str(version),
},
)
except Go2RtcClientError as err: except Go2RtcClientError as err:
if isinstance(err.__cause__, _RETRYABLE_ERRORS): if isinstance(err.__cause__, _RETRYABLE_ERRORS):
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Could not connect to go2rtc instance on {data.url}" f"Could not connect to go2rtc instance on {url}"
) from err ) from err
_LOGGER.warning( _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
"Could not connect to go2rtc instance on %s (%s)", data.url, err
)
return False return False
except Go2RtcVersionError as err: except Go2RtcVersionError as err:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"The go2rtc server version is not supported, {err}" f"The go2rtc server version is not supported, {err}"
) from err ) from err
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOGGER.warning( _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err)
"Could not connect to go2rtc instance on %s (%s)", data.url, err
)
return False return False
provider = WebRTCProvider(hass, data) provider = WebRTCProvider(hass, url)
async_register_webrtc_provider(hass, provider) async_register_webrtc_provider(hass, provider)
return True return True
@ -204,12 +206,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None:
class WebRTCProvider(CameraWebRTCProvider): class WebRTCProvider(CameraWebRTCProvider):
"""WebRTC provider.""" """WebRTC provider."""
def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None: def __init__(self, hass: HomeAssistant, url: str) -> None:
"""Initialize the WebRTC provider.""" """Initialize the WebRTC provider."""
self._hass = hass self._hass = hass
self._data = data self._url = url
self._session = async_get_clientsession(hass) self._session = async_get_clientsession(hass)
self._rest_client = Go2RtcRestClient(self._session, data.url) self._rest_client = Go2RtcRestClient(self._session, url)
self._sessions: dict[str, Go2RtcWsClient] = {} self._sessions: dict[str, Go2RtcWsClient] = {}
@property @property
@ -231,7 +233,7 @@ class WebRTCProvider(CameraWebRTCProvider):
) -> None: ) -> None:
"""Handle the WebRTC offer and return the answer via the provided callback.""" """Handle the WebRTC offer and return the answer via the provided callback."""
self._sessions[session_id] = ws_client = Go2RtcWsClient( self._sessions[session_id] = ws_client = Go2RtcWsClient(
self._session, self._data.url, source=camera.entity_id self._session, self._url, source=camera.entity_id
) )
if not (stream_source := await camera.stream_source()): if not (stream_source := await camera.stream_source()):
@ -242,34 +244,18 @@ class WebRTCProvider(CameraWebRTCProvider):
streams = await self._rest_client.streams.list() streams = await self._rest_client.streams.list()
if self._data.managed: if (stream := streams.get(camera.entity_id)) is None or not any(
# HA manages the go2rtc instance stream_source == producer.url for producer in stream.producers
stream_original_name = f"{camera.entity_id}_original"
stream_redirect_sources = [
f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}",
f"ffmpeg:{stream_original_name}#audio=opus",
]
if (
(stream_org := streams.get(stream_original_name)) is None
or not any(
stream_source == producer.url for producer in stream_org.producers
)
or (stream_redirect := streams.get(camera.entity_id)) is None
or stream_redirect_sources != [p.url for p in stream_redirect.producers]
):
await self._rest_client.streams.add(stream_original_name, stream_source)
await self._rest_client.streams.add(
camera.entity_id, stream_redirect_sources
)
# go2rtc instance is managed outside HA
elif (stream_org := streams.get(camera.entity_id)) is None or not any(
stream_source == producer.url for producer in stream_org.producers
): ):
await self._rest_client.streams.add( await self._rest_client.streams.add(
camera.entity_id, camera.entity_id,
[stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"], [
stream_source,
# We are setting any ffmpeg rtsp related logs to debug
# Connection problems to the camera will be logged by the first stream
# Therefore setting it to debug will not hide any important logs
f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug",
],
) )
@callback @callback

View file

@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984 HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
HA_MANAGED_RTSP_PORT = 18554 RECOMMENDED_VERSION = "1.9.7"

View file

@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/go2rtc", "documentation": "https://www.home-assistant.io/integrations/go2rtc",
"integration_type": "system", "integration_type": "system",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["go2rtc-client==0.1.0"], "requirements": ["go2rtc-client==0.1.1"],
"single_config_entry": true "single_config_entry": true
} }

View file

@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5 _TERMINATE_TIMEOUT = 5
@ -33,7 +33,7 @@ api:
listen: "{api_ip}:{api_port}" listen: "{api_ip}:{api_port}"
rtsp: rtsp:
listen: "127.0.0.1:{rtsp_port}" listen: "127.0.0.1:18554"
webrtc: webrtc:
listen: ":18555/tcp" listen: ":18555/tcp"
@ -68,9 +68,7 @@ def _create_temp_file(api_ip: str) -> str:
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
file.write( file.write(
_GO2RTC_CONFIG_FORMAT.format( _GO2RTC_CONFIG_FORMAT.format(
api_ip=api_ip, api_ip=api_ip, api_port=HA_MANAGED_API_PORT
api_port=HA_MANAGED_API_PORT,
rtsp_port=HA_MANAGED_RTSP_PORT,
).encode() ).encode()
) )
return file.name return file.name

View file

@ -0,0 +1,8 @@
{
"issues": {
"recommended_version": {
"title": "Outdated go2rtc server detected",
"description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`."
}
}
}

View file

@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
def set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Update the current value.""" """Update the current value."""
self._attr_native_value = value self._attr_native_value = value
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value) self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)

View file

@ -8,6 +8,7 @@ from aioautomower.exceptions import (
ApiException, ApiException,
AuthException, AuthException,
HusqvarnaWSServerHandshakeError, HusqvarnaWSServerHandshakeError,
TimeoutException,
) )
from aioautomower.model import MowerAttributes from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession from aioautomower.session import AutomowerSession
@ -22,6 +23,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MAX_WS_RECONNECT_TIME = 600 MAX_WS_RECONNECT_TIME = 600
SCAN_INTERVAL = timedelta(minutes=8) SCAN_INTERVAL = timedelta(minutes=8)
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
@ -40,8 +42,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
) )
self.api = api self.api = api
self.ws_connected: bool = False self.ws_connected: bool = False
self.reconnect_time = DEFAULT_RECONNECT_TIME
async def _async_update_data(self) -> dict[str, MowerAttributes]: async def _async_update_data(self) -> dict[str, MowerAttributes]:
"""Subscribe for websocket and poll data from the API.""" """Subscribe for websocket and poll data from the API."""
@ -66,24 +68,28 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
automower_client: AutomowerSession, automower_client: AutomowerSession,
reconnect_time: int = 2,
) -> None: ) -> None:
"""Listen with the client.""" """Listen with the client."""
try: try:
await automower_client.auth.websocket_connect() await automower_client.auth.websocket_connect()
reconnect_time = 2 # Reset reconnect time after successful connection
self.reconnect_time = DEFAULT_RECONNECT_TIME
await automower_client.start_listening() await automower_client.start_listening()
except HusqvarnaWSServerHandshakeError as err: except HusqvarnaWSServerHandshakeError as err:
_LOGGER.debug( _LOGGER.debug(
"Failed to connect to websocket. Trying to reconnect: %s", err "Failed to connect to websocket. Trying to reconnect: %s",
err,
)
except TimeoutException as err:
_LOGGER.debug(
"Failed to listen to websocket. Trying to reconnect: %s",
err,
) )
if not hass.is_stopping: if not hass.is_stopping:
await asyncio.sleep(reconnect_time) await asyncio.sleep(self.reconnect_time)
reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME) self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
await self.client_listen( entry.async_create_background_task(
hass=hass, hass,
entry=entry, self.client_listen(hass, entry, automower_client),
automower_client=automower_client, "reconnect_task",
reconnect_time=reconnect_time,
) )

View file

@ -3,30 +3,23 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, PLATFORMS from .const import DOMAIN, PLATFORMS
if sys.version_info < (3, 13):
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Huum from a config entry.""" """Set up Huum from a config entry."""
if sys.version_info >= (3, 13):
raise HomeAssistantError(
"Huum is not supported on Python 3.13. Please use Python 3.12."
)
username = entry.data[CONF_USERNAME] username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD] password = entry.data[CONF_PASSWORD]

View file

@ -3,9 +3,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from typing import Any from typing import Any
from huum.const import SaunaStatus
from huum.exceptions import SafetyException
from huum.huum import Huum
from huum.schemas import HuumStatusResponse
from homeassistant.components.climate import ( from homeassistant.components.climate import (
ClimateEntity, ClimateEntity,
ClimateEntityFeature, ClimateEntityFeature,
@ -20,12 +24,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
if sys.version_info < (3, 13):
from huum.const import SaunaStatus
from huum.exceptions import SafetyException
from huum.huum import Huum
from huum.schemas import HuumStatusResponse
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View file

@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import sys
from typing import Any from typing import Any
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@ -14,10 +15,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
if sys.version_info < (3, 13):
from huum.exceptions import Forbidden, NotAuthenticated
from huum.huum import Huum
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema( STEP_USER_DATA_SCHEMA = vol.Schema(

View file

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huum", "documentation": "https://www.home-assistant.io/integrations/huum",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["huum==0.7.11;python_version<'3.13'"] "requirements": ["huum==0.7.12"]
} }

View file

@ -20,7 +20,8 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
ADD_ENTITIES_CALLBACKS, ADD_ENTITIES_CALLBACKS,
@ -41,15 +42,26 @@ from .helpers import (
register_lcn_address_devices, register_lcn_address_devices,
register_lcn_host_device, register_lcn_host_device,
) )
from .services import SERVICES from .services import register_services
from .websocket import register_panel_and_ws_api from .websocket import register_panel_and_ws_api
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LCN component."""
hass.data.setdefault(DOMAIN, {})
await register_services(hass)
await register_panel_and_ws_api(hass)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up a connection to PCHK host from a config entry.""" """Set up a connection to PCHK host from a config entry."""
hass.data.setdefault(DOMAIN, {})
if config_entry.entry_id in hass.data[DOMAIN]: if config_entry.entry_id in hass.data[DOMAIN]:
return False return False
@ -109,15 +121,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
) )
lcn_connection.register_for_inputs(input_received) lcn_connection.register_for_inputs(input_received)
# register service calls
for service_name, service in SERVICES:
if not hass.services.has_service(DOMAIN, service_name):
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)
await register_panel_and_ws_api(hass)
return True return True
@ -168,11 +171,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
host = hass.data[DOMAIN].pop(config_entry.entry_id) host = hass.data[DOMAIN].pop(config_entry.entry_id)
await host[CONNECTION].async_close() await host[CONNECTION].async_close()
# unregister service calls
if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload
for service_name, _ in SERVICES:
hass.services.async_remove(DOMAIN, service_name)
return unload_ok return unload_ok

View file

@ -429,3 +429,11 @@ SERVICES = (
(LcnService.DYN_TEXT, DynText), (LcnService.DYN_TEXT, DynText),
(LcnService.PCK, Pck), (LcnService.PCK, Pck),
) )
async def register_services(hass: HomeAssistant) -> None:
"""Register services for LCN."""
for service_name, service in SERVICES:
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)

View file

@ -72,8 +72,11 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
super().__init__(coordinator, entity_description, property_id) super().__init__(coordinator, entity_description, property_id)
self._ordered_named_fan_speeds = [] self._ordered_named_fan_speeds = []
self._attr_supported_features |= FanEntityFeature.SET_SPEED self._attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
if (fan_modes := self.data.fan_modes) is not None: if (fan_modes := self.data.fan_modes) is not None:
self._attr_speed_count = len(fan_modes) self._attr_speed_count = len(fan_modes)
if self.speed_count == 4: if self.speed_count == 4:
@ -98,7 +101,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
self._attr_percentage = 0 self._attr_percentage = 0
_LOGGER.debug( _LOGGER.debug(
"[%s:%s] update status: %s -> %s (percntage=%s)", "[%s:%s] update status: %s -> %s (percentage=%s)",
self.coordinator.device_name, self.coordinator.device_name,
self.property_id, self.property_id,
self.data.is_on, self.data.is_on,
@ -120,7 +123,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
return return
_LOGGER.debug( _LOGGER.debug(
"[%s:%s] async_set_percentage. percntage=%s, value=%s", "[%s:%s] async_set_percentage. percentage=%s, value=%s",
self.coordinator.device_name, self.coordinator.device_name,
self.property_id, self.property_id,
percentage, percentage,

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill", "documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["mill", "mill_local"], "loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.11.8", "mill-local==0.3.0"] "requirements": ["millheater==0.12.2", "mill-local==0.3.0"]
} }

View file

@ -9,11 +9,13 @@ import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a ModernForms config flow.""" """Handle a ModernForms config flow."""
@ -55,17 +57,21 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None, prepare: bool = False self, user_input: dict[str, Any] | None = None, prepare: bool = False
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Config flow handler for ModernForms.""" """Config flow handler for ModernForms."""
source = self.context["source"]
# Request user input, unless we are preparing discovery flow # Request user input, unless we are preparing discovery flow
if user_input is None: if user_input is None:
user_input = {} user_input = {}
if not prepare: if not prepare:
if source == SOURCE_ZEROCONF: if self.source == SOURCE_ZEROCONF:
return self._show_confirm_dialog() return self.async_show_form(
return self._show_setup_form() step_id="zeroconf_confirm",
description_placeholders={"name": self.name},
)
return self.async_show_form(
step_id="user",
data_schema=USER_SCHEMA,
)
if source == SOURCE_ZEROCONF: if self.source == SOURCE_ZEROCONF:
user_input[CONF_HOST] = self.host user_input[CONF_HOST] = self.host
user_input[CONF_MAC] = self.mac user_input[CONF_MAC] = self.mac
@ -75,18 +81,21 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
try: try:
device = await device.update() device = await device.update()
except ModernFormsConnectionError: except ModernFormsConnectionError:
if source == SOURCE_ZEROCONF: if self.source == SOURCE_ZEROCONF:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
return self._show_setup_form({"base": "cannot_connect"}) return self.async_show_form(
step_id="user",
data_schema=USER_SCHEMA,
errors={"base": "cannot_connect"},
)
user_input[CONF_MAC] = device.info.mac_address user_input[CONF_MAC] = device.info.mac_address
user_input[CONF_NAME] = device.info.device_name
# Check if already configured # Check if already configured
await self.async_set_unique_id(user_input[CONF_MAC]) await self.async_set_unique_id(user_input[CONF_MAC])
self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]})
title = device.info.device_name title = device.info.device_name
if source == SOURCE_ZEROCONF: if self.source == SOURCE_ZEROCONF:
title = self.name title = self.name
if prepare: if prepare:
@ -96,19 +105,3 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN):
title=title, title=title,
data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]}, data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]},
) )
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
"""Show the setup form to the user."""
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors or {},
)
def _show_confirm_dialog(self, errors: dict | None = None) -> ConfigFlowResult:
"""Show the confirm dialog to the user."""
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={"name": self.name},
errors=errors or {},
)

View file

@ -4,9 +4,8 @@
"after_dependencies": ["media_source", "media_player"], "after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant"], "codeowners": ["@music-assistant"],
"config_flow": true, "config_flow": true,
"documentation": "https://music-assistant.io", "documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push", "iot_class": "local_push",
"issue_tracker": "https://github.com/music-assistant/hass-music-assistant/issues",
"loggers": ["music_assistant"], "loggers": ["music_assistant"],
"requirements": ["music-assistant-client==1.0.5"], "requirements": ["music-assistant-client==1.0.5"],
"zeroconf": ["_mass._tcp.local."] "zeroconf": ["_mass._tcp.local."]

View file

@ -4,7 +4,12 @@ from __future__ import annotations
from typing import Any from typing import Any
from pynordpool import Currency, NordPoolClient, NordPoolError from pynordpool import (
Currency,
NordPoolClient,
NordPoolEmptyResponseError,
NordPoolError,
)
from pynordpool.const import AREAS from pynordpool.const import AREAS
import voluptuous as vol import voluptuous as vol
@ -53,17 +58,16 @@ async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str,
"""Test fetch data from Nord Pool.""" """Test fetch data from Nord Pool."""
client = NordPoolClient(async_get_clientsession(hass)) client = NordPoolClient(async_get_clientsession(hass))
try: try:
data = await client.async_get_delivery_period( await client.async_get_delivery_period(
dt_util.now(), dt_util.now(),
Currency(user_input[CONF_CURRENCY]), Currency(user_input[CONF_CURRENCY]),
user_input[CONF_AREAS], user_input[CONF_AREAS],
) )
except NordPoolEmptyResponseError:
return {"base": "no_data"}
except NordPoolError: except NordPoolError:
return {"base": "cannot_connect"} return {"base": "cannot_connect"}
if not data.raw:
return {"base": "no_data"}
return {} return {}

View file

@ -9,8 +9,8 @@ from typing import TYPE_CHECKING
from pynordpool import ( from pynordpool import (
Currency, Currency,
DeliveryPeriodData, DeliveryPeriodData,
NordPoolAuthenticationError,
NordPoolClient, NordPoolClient,
NordPoolEmptyResponseError,
NordPoolError, NordPoolError,
NordPoolResponseError, NordPoolResponseError,
) )
@ -19,7 +19,7 @@ from homeassistant.const import CONF_CURRENCY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import CONF_AREAS, DOMAIN, LOGGER from .const import CONF_AREAS, DOMAIN, LOGGER
@ -75,8 +75,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]):
Currency(self.config_entry.data[CONF_CURRENCY]), Currency(self.config_entry.data[CONF_CURRENCY]),
self.config_entry.data[CONF_AREAS], self.config_entry.data[CONF_AREAS],
) )
except NordPoolAuthenticationError as error: except NordPoolEmptyResponseError as error:
LOGGER.error("Authentication error: %s", error) LOGGER.debug("Empty response error: %s", error)
self.async_set_update_error(error) self.async_set_update_error(error)
return return
except NordPoolResponseError as error: except NordPoolResponseError as error:
@ -88,8 +88,4 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]):
self.async_set_update_error(error) self.async_set_update_error(error)
return return
if not data.raw:
self.async_set_update_error(UpdateFailed("No data"))
return
self.async_set_updated_data(data) self.async_set_updated_data(data)

View file

@ -0,0 +1,16 @@
"""Diagnostics support for Nord Pool."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from . import NordPoolConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: NordPoolConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Nord Pool config entry."""
return {"raw": entry.runtime_data.data.raw}

View file

@ -28,7 +28,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import ( from homeassistant.util.unit_conversion import (
BaseUnitConverter, BaseUnitConverter,
BloodGlugoseConcentrationConverter, BloodGlucoseConcentrationConverter,
ConductivityConverter, ConductivityConverter,
DataRateConverter, DataRateConverter,
DistanceConverter, DistanceConverter,
@ -130,8 +130,8 @@ QUERY_STATISTICS_SUMMARY_SUM = (
STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
**{ **{
unit: BloodGlugoseConcentrationConverter unit: BloodGlucoseConcentrationConverter
for unit in BloodGlugoseConcentrationConverter.VALID_UNITS for unit in BloodGlucoseConcentrationConverter.VALID_UNITS
}, },
**{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS},
**{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS},

View file

@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_bytes from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import ( from homeassistant.util.unit_conversion import (
BloodGlugoseConcentrationConverter, BloodGlucoseConcentrationConverter,
ConductivityConverter, ConductivityConverter,
DataRateConverter, DataRateConverter,
DistanceConverter, DistanceConverter,
@ -56,7 +56,7 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10
UNIT_SCHEMA = vol.Schema( UNIT_SCHEMA = vol.Schema(
{ {
vol.Optional("blood_glucose_concentration"): vol.In( vol.Optional("blood_glucose_concentration"): vol.In(
BloodGlugoseConcentrationConverter.VALID_UNITS BloodGlucoseConcentrationConverter.VALID_UNITS
), ),
vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS),
vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS),

View file

@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink", "documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.10.4"] "requirements": ["reolink-aio==0.11.1"]
} }

View file

@ -96,7 +96,7 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity)
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
if alert := self._get_coordinator_alert(): if (alert := self._get_coordinator_alert()) and not alert.is_update:
self._async_handle_event(alert.kind) self._async_handle_event(alert.kind)
super()._handle_coordinator_update() super()._handle_coordinator_update()

View file

@ -30,5 +30,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["ring_doorbell"], "loggers": ["ring_doorbell"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["ring-doorbell==0.9.9"] "requirements": ["ring-doorbell==0.9.12"]
} }

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
@ -107,8 +106,12 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
async def _async_update_data(self) -> DeviceProp: async def _async_update_data(self) -> DeviceProp:
"""Update data via library.""" """Update data via library."""
try: try:
await asyncio.gather(*(self._update_device_prop(), self.get_rooms())) # Update device props and standard api information
await self._update_device_prop()
# Set the new map id from the updated device props
self._set_current_map() self._set_current_map()
# Get the rooms for that map id.
await self.get_rooms()
except RoborockException as ex: except RoborockException as ex:
raise UpdateFailed(ex) from ex raise UpdateFailed(ex) from ex
return self.roborock_device_info.props return self.roborock_device_info.props

View file

@ -135,6 +135,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
RoborockCommand.LOAD_MULTI_MAP, RoborockCommand.LOAD_MULTI_MAP,
[map_id], [map_id],
) )
# Update the current map id manually so that nothing gets broken
# if another service hits the api.
self.coordinator.current_map = map_id
# We need to wait after updating the map # We need to wait after updating the map
# so that other commands will be executed correctly. # so that other commands will be executed correctly.
await asyncio.sleep(MAP_SLEEP) await asyncio.sleep(MAP_SLEEP)
@ -148,6 +151,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
@property @property
def current_option(self) -> str | None: def current_option(self) -> str | None:
"""Get the current status of the select entity from device_status.""" """Get the current status of the select entity from device_status."""
if (current_map := self.coordinator.current_map) is not None: if (
(current_map := self.coordinator.current_map) is not None
and current_map in self.coordinator.maps
): # 63 means it is searching for a map.
return self.coordinator.maps[current_map].name return self.coordinator.maps[current_map].name
return None return None

View file

@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioruckus"], "loggers": ["aioruckus"],
"requirements": ["aioruckus==0.41"] "requirements": ["aioruckus==0.42"]
} }

View file

@ -48,7 +48,7 @@ from homeassistant.helpers.deprecation import (
) )
from homeassistant.util.unit_conversion import ( from homeassistant.util.unit_conversion import (
BaseUnitConverter, BaseUnitConverter,
BloodGlugoseConcentrationConverter, BloodGlucoseConcentrationConverter,
ConductivityConverter, ConductivityConverter,
DataRateConverter, DataRateConverter,
DistanceConverter, DistanceConverter,
@ -501,7 +501,7 @@ STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass]
UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = {
SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter,
SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlugoseConcentrationConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter,
SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter,
SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter,
SensorDeviceClass.DATA_RATE: DataRateConverter, SensorDeviceClass.DATA_RATE: DataRateConverter,

View file

@ -0,0 +1,39 @@
"""The Sky Remote Control integration."""
import logging
from skyboxremote import RemoteControl, SkyBoxConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
PLATFORMS = [Platform.REMOTE]
_LOGGER = logging.getLogger(__name__)
type SkyRemoteConfigEntry = ConfigEntry[RemoteControl]
async def async_setup_entry(hass: HomeAssistant, entry: SkyRemoteConfigEntry) -> bool:
"""Set up Sky remote."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
_LOGGER.debug("Setting up Host: %s, Port: %s", host, port)
remote = RemoteControl(host, port)
try:
await remote.check_connectable()
except SkyBoxConnectionError as e:
raise ConfigEntryNotReady from e
entry.runtime_data = remote
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,64 @@
"""Config flow for sky_remote."""
import logging
from typing import Any
from skyboxremote import RemoteControl, SkyBoxConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
import homeassistant.helpers.config_validation as cv
from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)
async def async_find_box_port(host: str) -> int:
"""Find port box uses for communication."""
logging.debug("Attempting to find port to connect to %s on", host)
remote = RemoteControl(host, DEFAULT_PORT)
try:
await remote.check_connectable()
except SkyBoxConnectionError:
# Try legacy port if the default one failed
remote = RemoteControl(host, LEGACY_PORT)
await remote.check_connectable()
return LEGACY_PORT
return DEFAULT_PORT
class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sky Remote."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""
errors: dict[str, str] = {}
if user_input is not None:
logging.debug("user_input: %s", user_input)
self._async_abort_entries_match(user_input)
try:
port = await async_find_box_port(user_input[CONF_HOST])
except SkyBoxConnectionError:
logging.exception("while finding port of skybox")
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=user_input[CONF_HOST],
data={**user_input, CONF_PORT: port},
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)

View file

@ -0,0 +1,6 @@
"""Constants."""
DOMAIN = "sky_remote"
DEFAULT_PORT = 49160
LEGACY_PORT = 5900

View file

@ -0,0 +1,10 @@
{
"domain": "sky_remote",
"name": "Sky Remote Control",
"codeowners": ["@dunnmj", "@saty9"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sky_remote",
"integration_type": "device",
"iot_class": "assumed_state",
"requirements": ["skyboxremote==0.0.6"]
}

View file

@ -0,0 +1,70 @@
"""Home Assistant integration to control a sky box using the remote platform."""
from collections.abc import Iterable
import logging
from typing import Any
from skyboxremote import VALID_KEYS, RemoteControl
from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SkyRemoteConfigEntry
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config: SkyRemoteConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Sky remote platform."""
async_add_entities(
[SkyRemote(config.runtime_data, config.entry_id)],
True,
)
class SkyRemote(RemoteEntity):
"""Representation of a Sky Remote."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, remote: RemoteControl, unique_id: str) -> None:
"""Initialize the Sky Remote."""
self._remote = remote
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
manufacturer="SKY",
model="Sky Box",
name=remote.host,
)
def turn_on(self, activity: str | None = None, **kwargs: Any) -> None:
"""Send the power on command."""
self.send_command(["sky"])
def turn_off(self, activity: str | None = None, **kwargs: Any) -> None:
"""Send the power command."""
self.send_command(["power"])
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a list of commands to the device."""
for cmd in command:
if cmd not in VALID_KEYS:
raise ServiceValidationError(
f"{cmd} is not in Valid Keys: {VALID_KEYS}"
)
try:
self._remote.send_keys(command)
except ValueError as err:
_LOGGER.error("Invalid command: %s. Error: %s", command, err)
return
_LOGGER.debug("Successfully sent command %s", command)

View file

@ -0,0 +1,21 @@
{
"config": {
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"step": {
"user": {
"title": "Add Sky Remote",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Hostname or IP address of your Sky device"
}
}
}
}
}

View file

@ -28,6 +28,10 @@
"deprecated_yaml_import_issue_auth_error": { "deprecated_yaml_import_issue_auth_error": {
"title": "YAML import failed due to an authentication error", "title": "YAML import failed due to an authentication error",
"description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "YAML import failed due to a connection error",
"description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
} }
}, },
"entity": { "entity": {

View file

@ -17,7 +17,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"unknown": "Unexpected error"
} }
}, },
"entity": { "entity": {

View file

@ -23,10 +23,10 @@
"state_characteristic": { "state_characteristic": {
"description": "Read the documention for further details on available options and how to use them.", "description": "Read the documention for further details on available options and how to use them.",
"data": { "data": {
"state_characteristic": "State_characteristic" "state_characteristic": "Statistic characteristic"
}, },
"data_description": { "data_description": {
"state_characteristic": "The characteristic that should be used as the state of the statistics sensor." "state_characteristic": "The statistic characteristic that should be used as the state of the sensor."
} }
}, },
"options": { "options": {

View file

@ -37,13 +37,13 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"incorrect_pin": "Incorrect PIN", "incorrect_pin": "Incorrect PIN",
"bad_pin_format": "PIN should be 4 digits", "bad_pin_format": "PIN should be 4 digits",
"two_factor_request_failed": "Request for 2FA code failed, please try again",
"bad_validation_code_format": "Validation code should be 6 digits", "bad_validation_code_format": "Validation code should be 6 digits",
"incorrect_validation_code": "Incorrect validation code" "incorrect_validation_code": "Incorrect validation code"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"two_factor_request_failed": "Request for 2FA code failed, please try again"
} }
}, },
"options": { "options": {

View file

@ -2,7 +2,7 @@
"domain": "template", "domain": "template",
"name": "Template", "name": "Template",
"after_dependencies": ["group"], "after_dependencies": ["group"],
"codeowners": ["@PhracturedBlue", "@tetienne", "@home-assistant/core"], "codeowners": ["@PhracturedBlue", "@home-assistant/core"],
"config_flow": true, "config_flow": true,
"dependencies": ["blueprint"], "dependencies": ["blueprint"],
"documentation": "https://www.home-assistant.io/integrations/template", "documentation": "https://www.home-assistant.io/integrations/template",

View file

@ -49,6 +49,7 @@ class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementati
def extra_authorize_data(self) -> dict[str, Any]: def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url.""" """Extra data that needs to be appended to the authorize url."""
return { return {
"prompt": "login",
"scope": " ".join(SCOPES), "scope": " ".join(SCOPES),
"code_challenge": self.code_challenge, # PKCE "code_challenge": self.code_challenge, # PKCE
} }
@ -83,4 +84,4 @@ class TeslaUserImplementation(AuthImplementation):
@property @property
def extra_authorize_data(self) -> dict[str, Any]: def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url.""" """Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(SCOPES)} return {"prompt": "login", "scope": " ".join(SCOPES)}

View file

@ -22,7 +22,7 @@ from .const import _LOGGER, DOMAIN, LINE_TYPES
from .coordinator import VodafoneStationRouter from .coordinator import VodafoneStationRouter
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
UPTIME_DEVIATION = 45 UPTIME_DEVIATION = 60
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)

View file

@ -1,4 +1,5 @@
{ {
"title": "Water heater",
"device_automation": { "device_automation": {
"action_type": { "action_type": {
"turn_on": "[%key:common::device_automation::action_type::turn_on%]", "turn_on": "[%key:common::device_automation::action_type::turn_on%]",
@ -7,7 +8,7 @@
}, },
"entity_component": { "entity_component": {
"_": { "_": {
"name": "Water heater", "name": "[%key:component::water_heater::title%]",
"state": { "state": {
"off": "[%key:common::state::off%]", "off": "[%key:common::state::off%]",
"eco": "Eco", "eco": "Eco",

View file

@ -9,5 +9,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aiowithings"], "loggers": ["aiowithings"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiowithings==3.1.1"] "requirements": ["aiowithings==3.1.3"]
} }

View file

@ -33,6 +33,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -104,25 +105,26 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
yellow_radio.description = "Yellow Zigbee module" yellow_radio.description = "Yellow Zigbee module"
yellow_radio.manufacturer = "Nabu Casa" yellow_radio.manufacturer = "Nabu Casa"
# Present the multi-PAN addon as a setup option, if it's available if is_hassio(hass):
multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( # Present the multi-PAN addon as a setup option, if it's available
hass multipan_manager = (
) await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass)
try:
addon_info = await multipan_manager.async_get_addon_info()
except (AddonError, KeyError):
addon_info = None
if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
addon_port = ListPortInfo(
device=silabs_multiprotocol_addon.get_zigbee_socket(),
skip_link_detection=True,
) )
addon_port.description = "Multiprotocol add-on" try:
addon_port.manufacturer = "Nabu Casa" addon_info = await multipan_manager.async_get_addon_info()
ports.append(addon_port) except (AddonError, KeyError):
addon_info = None
if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
addon_port = ListPortInfo(
device=silabs_multiprotocol_addon.get_zigbee_socket(),
skip_link_detection=True,
)
addon_port.description = "Multiprotocol add-on"
addon_port.manufacturer = "Nabu Casa"
ports.append(addon_port)
return ports return ports

View file

@ -10,7 +10,7 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["zwave_js_server"], "loggers": ["zwave_js_server"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.0"], "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.1"],
"usb": [ "usb": [
{ {
"vid": "0658", "vid": "0658",

View file

@ -29,9 +29,9 @@ PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
# Truthy date string triggers showing related deprecation warning messages. # Truthy date string triggers showing related deprecation warning messages.
REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2"
# Format for platform files # Format for platform files
PLATFORM_FORMAT: Final = "{platform}.{domain}" PLATFORM_FORMAT: Final = "{platform}.{domain}"

View file

@ -8,6 +8,26 @@ from __future__ import annotations
from typing import Final from typing import Final
BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
{
"domain": "acaia",
"manufacturer_id": 16962,
},
{
"domain": "acaia",
"local_name": "ACAIA*",
},
{
"domain": "acaia",
"local_name": "PYXIS-*",
},
{
"domain": "acaia",
"local_name": "LUNAR-*",
},
{
"domain": "acaia",
"local_name": "PROCHBT001",
},
{ {
"domain": "airthings_ble", "domain": "airthings_ble",
"manufacturer_id": 820, "manufacturer_id": 820,

View file

@ -24,6 +24,7 @@ FLOWS = {
], ],
"integration": [ "integration": [
"abode", "abode",
"acaia",
"accuweather", "accuweather",
"acmeda", "acmeda",
"adax", "adax",
@ -537,6 +538,7 @@ FLOWS = {
"simplefin", "simplefin",
"simplepush", "simplepush",
"simplisafe", "simplisafe",
"sky_remote",
"skybell", "skybell",
"slack", "slack",
"sleepiq", "sleepiq",

View file

@ -11,6 +11,12 @@
"config_flow": true, "config_flow": true,
"iot_class": "cloud_push" "iot_class": "cloud_push"
}, },
"acaia": {
"name": "Acaia",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
"accuweather": { "accuweather": {
"name": "AccuWeather", "name": "AccuWeather",
"integration_type": "service", "integration_type": "service",
@ -5608,11 +5614,22 @@
"config_flow": false, "config_flow": false,
"iot_class": "local_push" "iot_class": "local_push"
}, },
"sky_hub": { "sky": {
"name": "Sky Hub", "name": "Sky",
"integration_type": "hub", "integrations": {
"config_flow": false, "sky_hub": {
"iot_class": "local_polling" "integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling",
"name": "Sky Hub"
},
"sky_remote": {
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state",
"name": "Sky Remote Control"
}
}
}, },
"skybeacon": { "skybeacon": {
"name": "Skybeacon", "name": "Skybeacon",

View file

@ -719,14 +719,14 @@ def template(value: Any | None) -> template_helper.Template:
raise vol.Invalid("template value should be a string") raise vol.Invalid("template value should be a string")
if not (hass := _async_get_hass_or_none()): if not (hass := _async_get_hass_or_none()):
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .frame import report from .frame import ReportBehavior, report_usage
report( report_usage(
( (
"validates schema outside the event loop, " "validates schema outside the event loop, "
"which will stop working in HA Core 2025.10" "which will stop working in HA Core 2025.10"
), ),
error_if_core=False, core_behavior=ReportBehavior.LOG,
) )
template_value = template_helper.Template(str(value), hass) template_value = template_helper.Template(str(value), hass)
@ -748,14 +748,14 @@ def dynamic_template(value: Any | None) -> template_helper.Template:
raise vol.Invalid("template value does not contain a dynamic template") raise vol.Invalid("template value does not contain a dynamic template")
if not (hass := _async_get_hass_or_none()): if not (hass := _async_get_hass_or_none()):
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .frame import report from .frame import ReportBehavior, report_usage
report( report_usage(
( (
"validates schema outside the event loop, " "validates schema outside the event loop, "
"which will stop working in HA Core 2025.10" "which will stop working in HA Core 2025.10"
), ),
error_if_core=False, core_behavior=ReportBehavior.LOG,
) )
template_value = template_helper.Template(str(value), hass) template_value = template_helper.Template(str(value), hass)

View file

@ -997,14 +997,14 @@ class TrackTemplateResultInfo:
continue continue
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .frame import report from .frame import ReportBehavior, report_usage
report( report_usage(
( (
"calls async_track_template_result with template without hass, " "calls async_track_template_result with template without hass, "
"which will stop working in HA Core 2025.10" "which will stop working in HA Core 2025.10"
), ),
error_if_core=False, core_behavior=ReportBehavior.LOG,
) )
track_template_.template.hass = hass track_template_.template.hass = hass

View file

@ -1277,14 +1277,14 @@ def async_register_entity_service(
schema = cv.make_entity_service_schema(schema) schema = cv.make_entity_service_schema(schema)
elif not cv.is_entity_service_schema(schema): elif not cv.is_entity_service_schema(schema):
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .frame import report from .frame import ReportBehavior, report_usage
report( report_usage(
( (
"registers an entity service with a non entity service schema " "registers an entity service with a non entity service schema "
"which will stop working in HA Core 2025.9" "which will stop working in HA Core 2025.9"
), ),
error_if_core=False, core_behavior=ReportBehavior.LOG,
) )
service_func: str | HassJob[..., Any] service_func: str | HassJob[..., Any]

View file

@ -515,18 +515,18 @@ class Template:
will be non optional in Home Assistant Core 2025.10. will be non optional in Home Assistant Core 2025.10.
""" """
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from .frame import report from .frame import ReportBehavior, report_usage
if not isinstance(template, str): if not isinstance(template, str):
raise TypeError("Expected template to be a string") raise TypeError("Expected template to be a string")
if not hass: if not hass:
report( report_usage(
( (
"creates a template object without passing hass, " "creates a template object without passing hass, "
"which will stop working in HA Core 2025.10" "which will stop working in HA Core 2025.10"
), ),
error_if_core=False, core_behavior=ReportBehavior.LOG,
) )
self.template: str = template.strip() self.template: str = template.strip()

View file

@ -29,7 +29,7 @@ from homeassistant.util.dt import utcnow
from . import entity, event from . import entity, event
from .debounce import Debouncer from .debounce import Debouncer
from .frame import report from .frame import report_usage
from .typing import UNDEFINED, UndefinedType from .typing import UNDEFINED, UndefinedType
REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_COOLDOWN = 10
@ -286,24 +286,20 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
to ensure that multiple retries do not cause log spam. to ensure that multiple retries do not cause log spam.
""" """
if self.config_entry is None: if self.config_entry is None:
report( report_usage(
"uses `async_config_entry_first_refresh`, which is only supported " "uses `async_config_entry_first_refresh`, which is only supported "
"for coordinators with a config entry and will stop working in " "for coordinators with a config entry and will stop working in "
"Home Assistant 2025.11", "Home Assistant 2025.11"
error_if_core=True,
error_if_integration=False,
) )
elif ( elif (
self.config_entry.state self.config_entry.state
is not config_entries.ConfigEntryState.SETUP_IN_PROGRESS is not config_entries.ConfigEntryState.SETUP_IN_PROGRESS
): ):
report( report_usage(
"uses `async_config_entry_first_refresh`, which is only supported " "uses `async_config_entry_first_refresh`, which is only supported "
f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, " f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, "
f"but it is in state {self.config_entry.state}, " f"but it is in state {self.config_entry.state}, "
"This will stop working in Home Assistant 2025.11", "This will stop working in Home Assistant 2025.11",
error_if_core=True,
error_if_integration=False,
) )
if await self.__wrap_async_setup(): if await self.__wrap_async_setup():
await self._async_refresh( await self._async_refresh(

View file

@ -5,7 +5,7 @@ aiodiscover==2.1.0
aiodns==3.2.0 aiodns==3.2.0
aiohasupervisor==0.2.1 aiohasupervisor==0.2.1
aiohttp-fast-zlib==0.1.1 aiohttp-fast-zlib==0.1.1
aiohttp==3.11.0rc0 aiohttp==3.11.0
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
aiozoneinfo==0.2.1 aiozoneinfo==0.2.1
astral==2.2 astral==2.2
@ -28,14 +28,14 @@ ciso8601==2.3.1
cryptography==43.0.1 cryptography==43.0.1
dbus-fast==2.24.3 dbus-fast==2.24.3
fnv-hash-fast==1.0.2 fnv-hash-fast==1.0.2
go2rtc-client==0.1.0 go2rtc-client==0.1.1
ha-ffmpeg==3.2.2 ha-ffmpeg==3.2.2
habluetooth==3.6.0 habluetooth==3.6.0
hass-nabucasa==0.84.0 hass-nabucasa==0.84.0
hassil==1.7.4 hassil==2.0.1
home-assistant-bluetooth==1.13.0 home-assistant-bluetooth==1.13.0
home-assistant-frontend==20241106.2 home-assistant-frontend==20241106.2
home-assistant-intents==2024.11.6 home-assistant-intents==2024.11.13
httpx==0.27.2 httpx==0.27.2
ifaddr==0.2.0 ifaddr==0.2.0
Jinja2==3.1.4 Jinja2==3.1.4
@ -181,8 +181,8 @@ chacha20poly1305-reuseable>=0.13.0
# https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39 # https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39
pycountry>=23.12.11 pycountry>=23.12.11
# scapy<2.5.0 will not work with python3.12 # scapy==2.6.0 causes CI failures due to a race condition
scapy>=2.5.0 scapy>=2.6.1
# tuf isn't updated to deal with breaking changes in securesystemslib==1.0. # tuf isn't updated to deal with breaking changes in securesystemslib==1.0.
# Only tuf>=4 includes a constraint to <1.0. # Only tuf>=4 includes a constraint to <1.0.

View file

@ -174,7 +174,7 @@ class DistanceConverter(BaseUnitConverter):
} }
class BloodGlugoseConcentrationConverter(BaseUnitConverter): class BloodGlucoseConcentrationConverter(BaseUnitConverter):
"""Utility to convert blood glucose concentration values.""" """Utility to convert blood glucose concentration values."""
UNIT_CLASS = "blood_glucose_concentration" UNIT_CLASS = "blood_glucose_concentration"

View file

@ -19,6 +19,7 @@ classifiers = [
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Home Automation", "Topic :: Home Automation",
] ]
requires-python = ">=3.12.0" requires-python = ">=3.12.0"
@ -28,7 +29,7 @@ dependencies = [
# change behavior based on presence of supervisor. Deprecated with #127228 # change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11 # Lib can be removed with 2025.11
"aiohasupervisor==0.2.1", "aiohasupervisor==0.2.1",
"aiohttp==3.11.0rc0", "aiohttp==3.11.0",
"aiohttp_cors==0.7.0", "aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.1.1", "aiohttp-fast-zlib==0.1.1",
"aiozoneinfo==0.2.1", "aiozoneinfo==0.2.1",

View file

@ -5,7 +5,7 @@
# Home Assistant Core # Home Assistant Core
aiodns==3.2.0 aiodns==3.2.0
aiohasupervisor==0.2.1 aiohasupervisor==0.2.1
aiohttp==3.11.0rc0 aiohttp==3.11.0
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.1.1 aiohttp-fast-zlib==0.1.1
aiozoneinfo==0.2.1 aiozoneinfo==0.2.1

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