Compare commits

...

88 Commits

Author SHA1 Message Date
Franck Nijhof
f5b5215247 2023.10.2 (#101871) 2023-10-12 15:26:56 +02:00
Franck Nijhof
014546c75e Bumped version to 2023.10.2 2023-10-12 13:34:26 +02:00
Joost Lekkerkerker
b0dabfa3f7 Only reload Withings config entry on reauth (#101638)
* Only reload on reauth

* Reload if entry is loaded

* Make async_cloudhook_generate_url protected

* Fix feedback
2023-10-12 13:33:54 +02:00
Betacart
ca1d6ddbb6 Fix typo in remember the milk strings (#101869)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2023-10-12 13:32:45 +02:00
Martin Hjelmare
7670b5d3b0 Fix mysensors battery level attribute (#101868) 2023-10-12 13:32:39 +02:00
René Klomp
04dc44c069 Fix SMA incorrect device class (#101866) 2023-10-12 13:32:36 +02:00
Joost Lekkerkerker
c2cf497302 Fix translation key in Plugwise (#101862)
Co-authored-by: Robert Resch <robert@resch.dev>
2023-10-12 13:32:31 +02:00
Justin Lindh
34693d4a9b Bump Python-MyQ to v3.1.13 (#101852) 2023-10-12 13:31:46 +02:00
Brandon Rothweiler
c9b9851605 Remove Mazda integration (#101849)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2023-10-12 13:26:43 +02:00
Michael Hansen
3b13c9129a Close existing UDP server for ESPHome voice assistant (#101845) 2023-10-12 13:26:39 +02:00
Michael Hansen
ffe60102fd Dynamic wake word loading for Wyoming (#101827)
* Change supported_wake_words property to async method

* Add test

* Add timeout + test

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2023-10-12 13:26:34 +02:00
Álvaro Fernández Rojas
5946681454 Update aioqsw to v0.3.5 (#101809) 2023-10-12 13:26:31 +02:00
Kevin Worrel
eae6f9b0f8 Await set value function in ScreenLogic number entities (#101802) 2023-10-12 13:26:27 +02:00
Michael Davie
959d21a576 Bump env_canada to 0.6.0 (#101798) 2023-10-12 13:26:20 +02:00
Richard Kroegel
785df0c8e1 Bump bimmer_connected to 0.14.1 (#101789)
Co-authored-by: rikroe <rikroe@users.noreply.github.com>
2023-10-12 13:26:16 +02:00
Nathan Spencer
62805aed2b Bump pyweatherflowudp to 1.4.5 (#101770) 2023-10-12 13:26:12 +02:00
Hessel
1a2c9fd9a9 Change BiDirectional Prefix (#101764) 2023-10-12 13:26:09 +02:00
Joost Lekkerkerker
f0a1977d2e Subscribe to Withings webhooks outside of coordinator (#101759)
* Subscribe to Withings webhooks outside of coordinator

* Subscribe to Withings webhooks outside of coordinator

* Update homeassistant/components/withings/__init__.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/withings/__init__.py

Co-authored-by: J. Nick Koston <nick@koston.org>

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
2023-10-12 13:26:03 +02:00
Maikel Punie
8b3fc107df Bump pyDuotecno to 2023.10.0 (#101754) 2023-10-12 13:25:59 +02:00
Betacart
417ba3644b Fix typo in Ombi translation strings (#101747)
Update strings.json

Fixed typo ("Sumbit" -> "Submit")
2023-10-12 13:25:56 +02:00
Kevin Worrel
49f060d95b Bump screenlogicpy to 0.9.2 (#101746) 2023-10-12 13:25:52 +02:00
Álvaro Fernández Rojas
ed57d0beac Fix Airzone climate double setpoint (#101744)
Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
2023-10-12 13:25:49 +02:00
Allen Porter
d7a36cb6a4 Add google calendar required feature for create event service (#101741)
* Add google calendar required feature for create event service

* Update docstring
2023-10-12 13:25:44 +02:00
Brandon Rothweiler
887263d80e Update eufylife-ble-client to 0.1.8 (#101727) 2023-10-12 13:25:40 +02:00
Robert Hillis
c4737e4423 Fix Slack type error for file upload (#101720)
Fix regression
2023-10-12 13:25:37 +02:00
Jan Bouwhuis
327e6d2362 Fix mqtt sensor or binary_sensor state not saved after expiry (#101670)
Fix mqtt sensor state not saved after expire
2023-10-12 13:25:33 +02:00
Joost Lekkerkerker
e32044f884 Abort config flow when invalid token is received (#101642) 2023-10-12 13:25:28 +02:00
Allen Porter
dbc3382dfb Add additional calendar state alarm debugging (#101631) 2023-10-12 13:20:57 +02:00
Matthew Donoughe
a042703dd7 Update pylutron-caseta to 0.18.3 (#101630) 2023-10-12 13:20:53 +02:00
Aidan Timson
8109c77f6a Bump systembridgeconnector to 3.8.4 (#101621)
Update systembridgeconnector to 3.8.4
2023-10-12 13:20:49 +02:00
J. Nick Koston
2639602f5b Fix compiling missing statistics losing rows (#101616) 2023-10-12 13:20:46 +02:00
Allen Porter
d5c26beb91 Additional fix for rainbird unique id (#101599)
Additiona fix for rainbird unique id
2023-10-12 13:20:42 +02:00
Marc Mueller
f24843f211 Update aiohttp to 3.8.6 (#101590) 2023-10-12 13:20:39 +02:00
Joakim Plate
5f0bf4e2a3 Update ha-philipsjs to 3.1.1 (#101574)
Update philips to 3.1.1
2023-10-12 13:20:35 +02:00
Matthias Alphart
bab524f264 Update pyfronius to 0.7.2 (#101571) 2023-10-12 13:20:32 +02:00
Abílio Costa
ede7d13c1e Improve Ikea Idasen config flow error messages (#101567) 2023-10-12 13:20:29 +02:00
TJ Horner
db91e9a720 Auto-fix common key entry issues during WeatherKit config flow (#101504) 2023-10-12 13:20:25 +02:00
Greg Dowling
c11dd58c1d Improve handling of roon media players with fixed and incremental volume (#99819) 2023-10-12 13:20:20 +02:00
Franck Nijhof
a6edfa85b1 2023.10.1 (#101547) 2023-10-06 20:36:37 +02:00
Franck Nijhof
b3080ae005 Bumped version to 2023.10.1 2023-10-06 19:38:00 +02:00
Joost Lekkerkerker
5925b6b912 Only import color extractor when domain is in config (#101522) 2023-10-06 19:37:44 +02:00
Joost Lekkerkerker
42b53c6349 Add Withings webhooks after a slight delay (#101542) 2023-10-06 19:37:18 +02:00
J. Nick Koston
d26b1b370a Bump HAP-python to 4.8.0 (#101538) 2023-10-06 19:37:14 +02:00
Joost Lekkerkerker
7369ae8c9f Cancel callbacks on Withings entry unload (#101536) 2023-10-06 19:37:11 +02:00
Mike Woudenberg
6c2d1e2142 Update LoqedAPI to handle invalid transitions better (#101534) 2023-10-06 19:37:08 +02:00
jan iversen
4a5b0222ab Modbus, wrong length when reading strings (#101529) 2023-10-06 19:37:05 +02:00
Joost Lekkerkerker
76f78e249b Delete existing Withings cloudhook (#101527) 2023-10-06 19:37:02 +02:00
Joost Lekkerkerker
81f582eeb7 Use config flow in color extractor tests (#101524) 2023-10-06 19:36:58 +02:00
Kevin Stillhammer
7f6506cfcf Limit waze_travel_time to 0.5call/s over all entries (#101514) 2023-10-06 19:35:48 +02:00
Allen Porter
d14934861e Fix for rainbird unique id (#101512) 2023-10-06 19:35:44 +02:00
Allen Porter
d469626855 Fix bug in calendar state where alarms due to alarms not scheduled (#101510) 2023-10-06 19:35:41 +02:00
Ian
9725a0daf9 Fix key error in config flow when duplicate stop names exist (#101491) 2023-10-06 19:35:38 +02:00
J. Nick Koston
26c7ba38d0 Fix caching of latest short term stats after insertion of external stats (#101490) 2023-10-06 19:35:35 +02:00
Kevin Stillhammer
948bbdd2bf bump pywaze to 0.5.1 sets timeout to 60s (#101487) 2023-10-06 19:35:32 +02:00
J. Nick Koston
eadc70ede0 Bump zeroconf to 0.115.2 (#101482) 2023-10-06 19:35:28 +02:00
Paul Bottein
2210db4ca6 Update frontend to 20231005.0 (#101480) 2023-10-06 19:35:25 +02:00
G Johansson
c5585b0706 Fix Trafikverket Camera if no location data (#101463) 2023-10-06 19:35:22 +02:00
René Klomp
37cfa5efb7 SMA add missing entity descriptions (#101462) 2023-10-06 19:35:19 +02:00
Fredrik Erlandsson
a506ba94d1 Fix device_class.capitalize() in Point (#101440) 2023-10-06 19:35:16 +02:00
Michael Davie
f8c7d502df Bump env_canada to v0.5.37 (#101435) 2023-10-06 19:35:12 +02:00
Nathan Spencer
f0cb2ba005 Adjust WeatherFlow wind sensors to appropriately match native unit and library field (#101418) 2023-10-06 19:35:10 +02:00
Michael Hansen
f7ab00a8bf Add wake word cooldown to avoid duplicate wake-ups (#101417) 2023-10-06 19:35:06 +02:00
Joost Lekkerkerker
e8c38fe99e Add translation for Tamper binary sensor (#101416) 2023-10-06 19:35:03 +02:00
J. Nick Koston
a4f0da8286 Bump dbus-fast to 2.11.1 (#101406)
changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.11.0...v2.11.1
2023-10-06 19:34:54 +02:00
Jan Bouwhuis
223f3a434b Raise vol.Invalid for invalid mqtt device_tracker config (#101399)
Raise vol.Invalid for invalid mqtt device_tracker
2023-10-06 19:31:58 +02:00
Joost Lekkerkerker
10e43048bd Fix Withings translations (#101397) 2023-10-06 19:31:55 +02:00
Marty Sun
2345a2be5f Bump pyyardian to 1.1.1 (#101363)
* Update Yardian Dependency

* test requirements
2023-10-06 19:31:52 +02:00
TheJulianJES
7dfb397aef Fix ZHA device diagnostics error for unknown unsupported attributes (#101239)
* Modify test to account for scenario of unknown unsupported attributes

* Add error checking for finding unsupported attributes

* Change comment to clarify zigpy misses an attribute def

This should make it more clear that it's about an unknown attribute (where zigpy doesn't have an attribute definition).

* Increase test coverage

This increases test coverage by doing the following:
- adding the `IasZone` to our test device, so we have a cluster which actually has some attribute definitions
- adding not just an unknown unsupported attribute by id, but also by name
- adding a known unsupported attribute by id and by name

* Fix diagnostics logic
2023-10-06 19:31:48 +02:00
Franck Nijhof
22bf1a0582 2023.10.0 (#101386) 2023-10-04 16:03:55 +02:00
Franck Nijhof
01daae69ab Bumped version to 2023.10.0 2023-10-04 13:48:40 +02:00
Franck Nijhof
512b2af13c Bumped version to 2023.10.0b9 2023-10-04 10:24:20 +02:00
Franck Nijhof
8e05df2b44 Bumped version to 2023.10.0b8 2023-10-04 10:11:43 +02:00
Franck Nijhof
0470ca3e76 Update Pillow to 10.0.1 (#101368) 2023-10-04 10:11:33 +02:00
Guido Schmitz
ebde9914f2 Increase update interval of update platform in devolo_home_network (#101366)
Increase update interval of firmware platform
2023-10-04 10:11:30 +02:00
Luke Lashley
337f9197bb Check that dock error status is not None for Roborock (#101321)
Co-authored-by: Robert Resch <robert@resch.dev>
2023-10-04 10:11:27 +02:00
Brett Adams
9b9a16e9c6 Fix temperature when myZone is in use for Advantage air (#101316) 2023-10-04 10:11:21 +02:00
Paulus Schoutsen
55ff8e1fcb Bumped version to 2023.10.0b7 2023-10-03 22:07:38 -04:00
Jesse Hills
937a26117c Allow esphome device to disable vad on stream (#101352) 2023-10-03 22:03:20 -04:00
Jesse Hills
776b26de3f Fix manual stopping of the voice assistant pipeline (#101351) 2023-10-03 22:03:19 -04:00
Michael Hansen
b9a929e63b Pipeline runs are only equal with same id (#101341)
* Pipeline runs are only equal with same id

* Use dict instead of list in PipelineRuns

* Let it blow up

* Test

* Test rest of __eq__
2023-10-03 22:03:18 -04:00
Michael Hansen
9c5d9344e2 Increase pipeline timeout to 5 minutes (#101327) 2023-10-03 22:03:17 -04:00
Michael
38423ad6f1 Bump pyW800rf32 to 0.4 (#101317)
bump pyW800rf32 from 0.3 to 0.4
2023-10-03 22:03:16 -04:00
Brett Adams
9e4f9a88ad Fix reference error in Aussie Broadband (#101315) 2023-10-03 22:03:15 -04:00
Brett Adams
e0cbbf7d57 Revert PR #99077 for Aussie Broadband (#101314) 2023-10-03 22:03:14 -04:00
Aaron Collins
fd6eb61489 Remove duplicated device before daikin migration (#99900)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2023-10-03 22:03:13 -04:00
Franck Nijhof
a9bc380c32 Bumped version to 2023.10.0b6 2023-10-02 23:03:02 +02:00
Franck Nijhof
be32db70a0 Update Lokalise CLI to v2.6.8 (#101297) 2023-10-02 23:02:53 +02:00
Bram Kragten
0e29ccf069 Update frontend to 20231002.0 (#101294) 2023-10-02 23:02:49 +02:00
183 changed files with 1869 additions and 4152 deletions

View File

@@ -738,8 +738,6 @@ build.json @home-assistant/supervisor
/tests/components/matrix/ @PaarthShah
/homeassistant/components/matter/ @home-assistant/matter
/tests/components/matter/ @home-assistant/matter
/homeassistant/components/mazda/ @bdr99
/tests/components/mazda/ @bdr99
/homeassistant/components/meater/ @Sotolotl @emontnemery
/tests/components/meater/ @Sotolotl @emontnemery
/homeassistant/components/medcom_ble/ @elafargue

View File

@@ -125,6 +125,13 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the current target temperature."""
# If the system is in MyZone mode, and a zone is set, return that temperature instead.
if (
self._ac["myZone"] > 0
and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED)
and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED)
):
return self._myzone["setTemp"]
return self._ac["setTemp"]
@property

View File

@@ -62,6 +62,12 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
def _ac(self) -> dict[str, Any]:
return self.coordinator.data["aircons"][self.ac_key]["info"]
@property
def _myzone(self) -> dict[str, Any]:
return self.coordinator.data["aircons"][self.ac_key]["zones"].get(
f"z{self._ac['myZone']:02}"
)
class AdvantageAirZoneEntity(AdvantageAirAcEntity):
"""Parent class for Advantage Air Zone Entities."""

View File

@@ -217,8 +217,8 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
if ATTR_TEMPERATURE in kwargs:
params[API_SET_POINT] = kwargs[ATTR_TEMPERATURE]
if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs:
params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW]
params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH]
params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH]
params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW]
await self._async_update_hvac_params(params)
@callback
@@ -248,8 +248,8 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED))
if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
self._attr_target_temperature_high = self.get_airzone_value(
AZD_HEAT_TEMP_SET
)
self._attr_target_temperature_low = self.get_airzone_value(
AZD_COOL_TEMP_SET
)
self._attr_target_temperature_low = self.get_airzone_value(
AZD_HEAT_TEMP_SET
)

View File

@@ -9,7 +9,7 @@ from homeassistant.components import stt
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .const import DATA_CONFIG, DOMAIN
from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DOMAIN
from .error import PipelineNotFound
from .pipeline import (
AudioSettings,
@@ -45,7 +45,9 @@ __all__ = (
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{vol.Optional("debug_recording_dir"): str},
{
vol.Optional(CONF_DEBUG_RECORDING_DIR): str,
},
)
},
extra=vol.ALLOW_EXTRA,

View File

@@ -2,3 +2,12 @@
DOMAIN = "assist_pipeline"
DATA_CONFIG = f"{DOMAIN}.config"
DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds
DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds
CONF_DEBUG_RECORDING_DIR = "debug_recording_dir"
DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up"
DEFAULT_WAKE_WORD_COOLDOWN = 2 # seconds

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import array
import asyncio
from collections import deque
from collections import defaultdict, deque
from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable
from dataclasses import asdict, dataclass, field
from enum import StrEnum
@@ -48,7 +48,13 @@ from homeassistant.util import (
)
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .const import DATA_CONFIG, DOMAIN
from .const import (
CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG,
DATA_LAST_WAKE_UP,
DEFAULT_WAKE_WORD_COOLDOWN,
DOMAIN,
)
from .error import (
IntentRecognitionError,
PipelineError,
@@ -399,6 +405,9 @@ class WakeWordSettings:
audio_seconds_to_buffer: float = 0
"""Seconds of audio to buffer before detection and forward to STT."""
cooldown_seconds: float = DEFAULT_WAKE_WORD_COOLDOWN
"""Seconds after a wake word detection where other detections are ignored."""
@dataclass(frozen=True)
class AudioSettings:
@@ -475,7 +484,7 @@ class PipelineRun:
stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False)
tts_engine: str = field(init=False, repr=False)
tts_options: dict | None = field(init=False, default=None)
wake_word_entity_id: str = field(init=False, repr=False)
wake_word_entity_id: str | None = field(init=False, default=None, repr=False)
wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False)
abort_wake_word_detection: bool = field(init=False, default=False)
@@ -518,6 +527,13 @@ class PipelineRun:
self.audio_settings.noise_suppression_level,
)
def __eq__(self, other: Any) -> bool:
"""Compare pipeline runs by id."""
if isinstance(other, PipelineRun):
return self.id == other.id
return False
@callback
def process_event(self, event: PipelineEvent) -> None:
"""Log an event and call listener."""
@@ -596,6 +612,8 @@ class PipelineRun:
)
)
wake_word_settings = self.wake_word_settings or WakeWordSettings()
# Remove language since it doesn't apply to wake words yet
metadata_dict.pop("language", None)
@@ -605,6 +623,7 @@ class PipelineRun:
{
"entity_id": self.wake_word_entity_id,
"metadata": metadata_dict,
"timeout": wake_word_settings.timeout or 0,
},
)
)
@@ -612,8 +631,6 @@ class PipelineRun:
if self.debug_recording_queue is not None:
self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_entity_id}")
wake_word_settings = self.wake_word_settings or WakeWordSettings()
wake_word_vad: VoiceActivityTimeout | None = None
if (wake_word_settings.timeout is not None) and (
wake_word_settings.timeout > 0
@@ -663,6 +680,17 @@ class PipelineRun:
if result is None:
wake_word_output: dict[str, Any] = {}
else:
# Avoid duplicate detections by checking cooldown
last_wake_up = self.hass.data.get(DATA_LAST_WAKE_UP)
if last_wake_up is not None:
sec_since_last_wake_up = time.monotonic() - last_wake_up
if sec_since_last_wake_up < wake_word_settings.cooldown_seconds:
_LOGGER.debug("Duplicate wake word detection occurred")
raise WakeWordDetectionAborted
# Record last wake up time to block duplicate detections
self.hass.data[DATA_LAST_WAKE_UP] = time.monotonic()
if result.queued_audio:
# Add audio that was pending at detection.
#
@@ -1025,7 +1053,7 @@ class PipelineRun:
# Directory to save audio for each pipeline run.
# Configured in YAML for assist_pipeline.
if debug_recording_dir := self.hass.data[DATA_CONFIG].get(
"debug_recording_dir"
CONF_DEBUG_RECORDING_DIR
):
if device_id is None:
# <debug_recording_dir>/<pipeline.name>/<run.id>
@@ -1565,21 +1593,19 @@ class PipelineRuns:
def __init__(self, pipeline_store: PipelineStorageCollection) -> None:
"""Initialize."""
self._pipeline_runs: dict[str, list[PipelineRun]] = {}
self._pipeline_runs: dict[str, dict[str, PipelineRun]] = defaultdict(dict)
self._pipeline_store = pipeline_store
pipeline_store.async_add_listener(self._change_listener)
def add_run(self, pipeline_run: PipelineRun) -> None:
"""Add pipeline run."""
pipeline_id = pipeline_run.pipeline.id
if pipeline_id not in self._pipeline_runs:
self._pipeline_runs[pipeline_id] = []
self._pipeline_runs[pipeline_id].append(pipeline_run)
self._pipeline_runs[pipeline_id][pipeline_run.id] = pipeline_run
def remove_run(self, pipeline_run: PipelineRun) -> None:
"""Remove pipeline run."""
pipeline_id = pipeline_run.pipeline.id
self._pipeline_runs[pipeline_id].remove(pipeline_run)
self._pipeline_runs[pipeline_id].pop(pipeline_run.id)
async def _change_listener(
self, change_type: str, item_id: str, change: dict
@@ -1589,7 +1615,7 @@ class PipelineRuns:
return
if pipeline_runs := self._pipeline_runs.get(item_id):
# Create a temporary list in case the list is modified while we iterate
for pipeline_run in list(pipeline_runs):
for pipeline_run in list(pipeline_runs.values()):
pipeline_run.abort_wake_word_detection = True

View File

@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.util import language as language_util
from .const import DOMAIN
from .const import DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, DOMAIN
from .error import PipelineNotFound
from .pipeline import (
AudioSettings,
@@ -30,9 +30,6 @@ from .pipeline import (
async_get_pipeline,
)
DEFAULT_TIMEOUT = 30
DEFAULT_WAKE_WORD_TIMEOUT = 3
_LOGGER = logging.getLogger(__name__)
@@ -117,7 +114,7 @@ async def websocket_run(
)
return
timeout = msg.get("timeout", DEFAULT_TIMEOUT)
timeout = msg.get("timeout", DEFAULT_PIPELINE_TIMEOUT)
start_stage = PipelineStage(msg["start_stage"])
end_stage = PipelineStage(msg["end_stage"])
handler_id: int | None = None

View File

@@ -3,11 +3,10 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientError
from aussiebb.asyncio import AussieBB
from aussiebb.const import FETCH_TYPES, NBN_TYPES, PHONE_TYPES
from aussiebb.const import FETCH_TYPES
from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType
from homeassistant.config_entries import ConfigEntry
@@ -23,19 +22,6 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
# Backport for the pyaussiebb=0.0.15 validate_service_type method
def validate_service_type(service: dict[str, Any]) -> None:
"""Check the service types against known types."""
if "type" not in service:
raise ValueError("Field 'type' not found in service data")
if service["type"] not in NBN_TYPES + PHONE_TYPES + ["Hardware"]:
raise UnrecognisedServiceType(
f"Service type {service['type']=} {service['name']=} - not recognised - ",
"please report this at https://github.com/yaleman/aussiebb/issues/new",
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Aussie Broadband from a config entry."""
# Login to the Aussie Broadband API and retrieve the current service list
@@ -44,9 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.data[CONF_PASSWORD],
async_get_clientsession(hass),
)
# Overwrite the pyaussiebb=0.0.15 validate_service_type method with backport
# Required until pydantic 2.x is supported
client.validate_service_type = validate_service_type
try:
await client.login()
services = await client.get_services(drop_types=FETCH_TYPES)
@@ -61,10 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
return await client.get_usage(service_id)
except UnrecognisedServiceType as err:
raise UpdateFailed(
f"Service {service_id} of type '{services[service_id]['type']}' was"
" unrecognised"
) from err
raise UpdateFailed(f"Service {service_id} was unrecognised") from err
return async_update_data

View File

@@ -286,6 +286,13 @@
"on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
}
},
"tamper": {
"name": "Tamper",
"state": {
"off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
"on": "Tampering detected"
}
},
"update": {
"name": "Update",
"state": {

View File

@@ -19,6 +19,6 @@
"bluetooth-adapters==0.16.1",
"bluetooth-auto-recovery==1.2.3",
"bluetooth-data-tools==1.12.0",
"dbus-fast==2.11.0"
"dbus-fast==2.11.1"
]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"iot_class": "cloud_polling",
"loggers": ["bimmer_connected"],
"requirements": ["bimmer-connected==0.14.0"]
"requirements": ["bimmer-connected==0.14.1"]
}

View File

@@ -528,7 +528,9 @@ class CalendarEntity(Entity):
the current or upcoming event.
"""
super().async_write_ha_state()
_LOGGER.debug(
"Clearing %s alarms (%s)", self.entity_id, len(self._alarm_unsubs)
)
for unsub in self._alarm_unsubs:
unsub()
self._alarm_unsubs.clear()
@@ -536,13 +538,14 @@ class CalendarEntity(Entity):
now = dt_util.now()
event = self.event
if event is None or now >= event.end_datetime_local:
_LOGGER.debug("No alarms needed for %s (event=%s)", self.entity_id, event)
return
@callback
def update(_: datetime.datetime) -> None:
"""Run when the active or upcoming event starts or ends."""
"""Update state and reschedule next alarms."""
_LOGGER.debug("Running %s update", self.entity_id)
self._async_write_ha_state()
self.async_write_ha_state()
if now < event.start_datetime_local:
self._alarm_unsubs.append(

View File

@@ -24,7 +24,10 @@ from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
CONFIG_SCHEMA = vol.Schema(
{vol.Optional(DOMAIN): {}},
extra=vol.ALLOW_EXTRA,
)
# Extend the existing light.turn_on service schema
SERVICE_SCHEMA = vol.All(
@@ -62,11 +65,12 @@ def _get_color(file_handler) -> tuple:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Color extractor component."""
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data={}
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data={}
)
)
)
return True

View File

@@ -135,9 +135,11 @@ async def async_migrate_unique_id(
) -> None:
"""Migrate old entry."""
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
old_unique_id = config_entry.unique_id
new_unique_id = api.device.mac
new_name = api.device.values.get("name")
new_mac = dr.format_mac(new_unique_id)
new_name = api.name
@callback
def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
@@ -147,15 +149,36 @@ async def async_migrate_unique_id(
if new_unique_id == old_unique_id:
return
duplicate = dev_reg.async_get_device(
connections={(CONNECTION_NETWORK_MAC, new_mac)}, identifiers=None
)
# Remove duplicated device
if duplicate is not None:
if config_entry.entry_id in duplicate.config_entries:
_LOGGER.debug(
"Removing duplicated device %s",
duplicate.name,
)
# The automatic cleanup in entity registry is scheduled as a task, remove
# the entities manually to avoid unique_id collision when the entities
# are migrated.
duplicate_entities = er.async_entries_for_device(
ent_reg, duplicate.id, True
)
for entity in duplicate_entities:
ent_reg.async_remove(entity.entity_id)
dev_reg.async_remove_device(duplicate.id)
# Migrate devices
for device_entry in dr.async_entries_for_config_entry(
dev_reg, config_entry.entry_id
):
for connection in device_entry.connections:
if connection[1] == old_unique_id:
new_connections = {
(CONNECTION_NETWORK_MAC, dr.format_mac(new_unique_id))
}
new_connections = {(CONNECTION_NETWORK_MAC, new_mac)}
_LOGGER.debug(
"Migrating device %s connections to %s",

View File

@@ -35,6 +35,7 @@ from .const import (
CONNECTED_PLC_DEVICES,
CONNECTED_WIFI_CLIENTS,
DOMAIN,
FIRMWARE_UPDATE_INTERVAL,
LONG_UPDATE_INTERVAL,
NEIGHBORING_WIFI_NETWORKS,
REGULAR_FIRMWARE,
@@ -146,7 +147,7 @@ async def async_setup_entry( # noqa: C901
_LOGGER,
name=REGULAR_FIRMWARE,
update_method=async_update_firmware_available,
update_interval=LONG_UPDATE_INTERVAL,
update_interval=FIRMWARE_UPDATE_INTERVAL,
)
if device.device and "wifi1" in device.device.features:
coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator(

View File

@@ -14,6 +14,7 @@ PRODUCT = "product"
SERIAL_NUMBER = "serial_number"
TITLE = "title"
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=5)
LONG_UPDATE_INTERVAL = timedelta(minutes=5)
SHORT_UPDATE_INTERVAL = timedelta(seconds=15)

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/doods",
"iot_class": "local_polling",
"loggers": ["pydoods"],
"requirements": ["pydoods==1.0.2", "Pillow==10.0.0"]
"requirements": ["pydoods==1.0.2", "Pillow==10.0.1"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/duotecno",
"iot_class": "local_push",
"requirements": ["pyDuotecno==2023.9.0"]
"requirements": ["pyDuotecno==2023.10.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.5.36"]
"requirements": ["env-canada==0.6.0"]
}

View File

@@ -327,20 +327,23 @@ class ESPHomeManager:
) -> int | None:
"""Start a voice assistant pipeline."""
if self.voice_assistant_udp_server is not None:
return None
_LOGGER.warning("Voice assistant UDP server was not stopped")
self.voice_assistant_udp_server.stop()
self.voice_assistant_udp_server.close()
self.voice_assistant_udp_server = None
hass = self.hass
voice_assistant_udp_server = VoiceAssistantUDPServer(
self.voice_assistant_udp_server = VoiceAssistantUDPServer(
hass,
self.entry_data,
self._handle_pipeline_event,
self._handle_pipeline_finished,
)
port = await voice_assistant_udp_server.start_server()
port = await self.voice_assistant_udp_server.start_server()
assert self.device_id is not None, "Device ID must be set"
hass.async_create_background_task(
voice_assistant_udp_server.run_pipeline(
self.voice_assistant_udp_server.run_pipeline(
device_id=self.device_id,
conversation_id=conversation_id or None,
flags=flags,

View File

@@ -260,6 +260,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
noise_suppression_level=audio_settings.noise_suppression_level,
auto_gain_dbfs=audio_settings.auto_gain,
volume_multiplier=audio_settings.volume_multiplier,
is_vad_enabled=bool(flags & VoiceAssistantCommandFlag.USE_VAD),
),
)

View File

@@ -24,5 +24,5 @@
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["eufylife-ble-client==0.1.7"]
"requirements": ["eufylife-ble-client==0.1.8"]
}

View File

@@ -12,5 +12,5 @@
"iot_class": "local_polling",
"loggers": ["pyfronius"],
"quality_scale": "platinum",
"requirements": ["PyFronius==0.7.1"]
"requirements": ["PyFronius==0.7.2"]
}

View File

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

View File

@@ -6,5 +6,5 @@
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/generic",
"iot_class": "local_push",
"requirements": ["ha-av==10.1.1", "Pillow==10.0.0"]
"requirements": ["ha-av==10.1.1", "Pillow==10.0.1"]
}

View File

@@ -240,6 +240,7 @@ async def async_setup_entry(
SERVICE_CREATE_EVENT,
CREATE_EVENT_SCHEMA,
async_create_event,
required_features=CalendarEntityFeature.CREATE_EVENT,
)

View File

@@ -9,7 +9,7 @@
"iot_class": "local_push",
"loggers": ["pyhap"],
"requirements": [
"HAP-python==4.7.1",
"HAP-python==4.8.0",
"fnv-hash-fast==0.4.1",
"PyQRCode==1.2.1",
"base36==0.1.1"

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
import logging
from typing import Any
from bleak import BleakError
from bleak.exc import BleakError
from bluetooth_data_tools import human_readable_name
from idasen_ha import Desk
from idasen_ha import AuthFailedError, Desk
import voluptuous as vol
from homeassistant import config_entries
@@ -64,6 +64,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
desk = Desk(None)
try:
await desk.connect(discovery_info.device, monitor_height=False)
except AuthFailedError as err:
_LOGGER.exception("AuthFailedError", exc_info=err)
errors["base"] = "auth_failed"
except TimeoutError as err:
_LOGGER.exception("TimeoutError", exc_info=err)
errors["base"] = "cannot_connect"

View File

@@ -11,5 +11,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/idasen_desk",
"iot_class": "local_push",
"requirements": ["idasen-ha==1.4"]
"requirements": ["idasen-ha==1.4.1"]
}

View File

@@ -9,7 +9,8 @@
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"auth_failed": "Unable to authenticate with the desk. This is usually solved by using an ESPHome Bluetooth Proxy. Please check the integration documentation for alternative workarounds.",
"cannot_connect": "Cannot connect. Make sure that the desk is in Bluetooth pairing mode. If not already, you can also use an ESPHome Bluetooth Proxy, as it provides a better connection.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["Pillow==10.0.0"]
"requirements": ["Pillow==10.0.1"]
}

View File

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

View File

@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
"requirements": ["pylutron-caseta==0.18.2"],
"requirements": ["pylutron-caseta==0.18.3"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/matrix",
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"requirements": ["matrix-nio==0.21.2", "Pillow==10.0.0"]
"requirements": ["matrix-nio==0.21.2", "Pillow==10.0.1"]
}

View File

@@ -1,213 +1,26 @@
"""The Mazda Connected Services integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from pymazda import (
Client as MazdaAPI,
MazdaAccountLockedException,
MazdaAPIEncryptionException,
MazdaAuthenticationException,
MazdaException,
MazdaTokenExpiredException,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import (
aiohttp_client,
config_validation as cv,
device_registry as dr,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_REGION, DATA_VEHICLES, DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.DEVICE_TRACKER,
Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH,
]
DOMAIN = "mazda"
async def with_timeout(task, timeout_seconds=30):
"""Run an async task with a timeout."""
async with asyncio.timeout(timeout_seconds):
return await task
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
"""Set up Mazda Connected Services from a config entry."""
email = entry.data[CONF_EMAIL]
password = entry.data[CONF_PASSWORD]
region = entry.data[CONF_REGION]
websession = aiohttp_client.async_get_clientsession(hass)
mazda_client = MazdaAPI(
email, password, region, websession=websession, use_cached_vehicle_list=True
)
try:
await mazda_client.validate_credentials()
except MazdaAuthenticationException as ex:
raise ConfigEntryAuthFailed from ex
except (
MazdaException,
MazdaAccountLockedException,
MazdaTokenExpiredException,
MazdaAPIEncryptionException,
) as ex:
_LOGGER.error("Error occurred during Mazda login request: %s", ex)
raise ConfigEntryNotReady from ex
async def async_handle_service_call(service_call: ServiceCall) -> None:
"""Handle a service call."""
# Get device entry from device registry
dev_reg = dr.async_get(hass)
device_id = service_call.data["device_id"]
device_entry = dev_reg.async_get(device_id)
if TYPE_CHECKING:
# For mypy: it has already been checked in validate_mazda_device_id
assert device_entry
# Get vehicle VIN from device identifiers
mazda_identifiers = (
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
)
vin_identifier = next(mazda_identifiers)
vin = vin_identifier[1]
# Get vehicle ID and API client from hass.data
vehicle_id = 0
api_client = None
for entry_data in hass.data[DOMAIN].values():
for vehicle in entry_data[DATA_VEHICLES]:
if vehicle["vin"] == vin:
vehicle_id = vehicle["id"]
api_client = entry_data[DATA_CLIENT]
break
if vehicle_id == 0 or api_client is None:
raise HomeAssistantError("Vehicle ID not found")
api_method = getattr(api_client, service_call.service)
try:
latitude = service_call.data["latitude"]
longitude = service_call.data["longitude"]
poi_name = service_call.data["poi_name"]
await api_method(vehicle_id, latitude, longitude, poi_name)
except Exception as ex:
raise HomeAssistantError(ex) from ex
def validate_mazda_device_id(device_id):
"""Check that a device ID exists in the registry and has at least one 'mazda' identifier."""
dev_reg = dr.async_get(hass)
if (device_entry := dev_reg.async_get(device_id)) is None:
raise vol.Invalid("Invalid device ID")
mazda_identifiers = [
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
]
if not mazda_identifiers:
raise vol.Invalid("Device ID is not a Mazda vehicle")
return device_id
service_schema_send_poi = vol.Schema(
{
vol.Required("device_id"): vol.All(cv.string, validate_mazda_device_id),
vol.Required("latitude"): cv.latitude,
vol.Required("longitude"): cv.longitude,
vol.Required("poi_name"): cv.string,
}
)
async def async_update_data():
"""Fetch data from Mazda API."""
try:
vehicles = await with_timeout(mazda_client.get_vehicles())
# The Mazda API can throw an error when multiple simultaneous requests are
# made for the same account, so we can only make one request at a time here
for vehicle in vehicles:
vehicle["status"] = await with_timeout(
mazda_client.get_vehicle_status(vehicle["id"])
)
# If vehicle is electric, get additional EV-specific status info
if vehicle["isElectric"]:
vehicle["evStatus"] = await with_timeout(
mazda_client.get_ev_vehicle_status(vehicle["id"])
)
vehicle["hvacSetting"] = await with_timeout(
mazda_client.get_hvac_setting(vehicle["id"])
)
hass.data[DOMAIN][entry.entry_id][DATA_VEHICLES] = vehicles
return vehicles
except MazdaAuthenticationException as ex:
raise ConfigEntryAuthFailed("Not authenticated with Mazda API") from ex
except Exception as ex:
_LOGGER.exception(
"Unknown error occurred during Mazda update request: %s", ex
)
raise UpdateFailed(ex) from ex
coordinator = DataUpdateCoordinator(
ir.async_create_issue(
hass,
_LOGGER,
name=DOMAIN,
update_method=async_update_data,
update_interval=timedelta(seconds=180),
)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_CLIENT: mazda_client,
DATA_COORDINATOR: coordinator,
DATA_REGION: region,
DATA_VEHICLES: [],
}
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()
# Setup components
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register services
hass.services.async_register(
DOMAIN,
"send_poi",
async_handle_service_call,
schema=service_schema_send_poi,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"dmca": "https://github.com/github/dmca/blob/master/2023/10/2023-10-10-mazda.md",
"entries": "/config/integrations/integration/mazda",
},
)
return True
@@ -215,45 +28,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Only remove services if it is the last config entry
if len(hass.data[DOMAIN]) == 1:
hass.services.async_remove(DOMAIN, "send_poi")
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class MazdaEntity(CoordinatorEntity):
"""Defines a base Mazda entity."""
_attr_has_entity_name = True
def __init__(self, client, coordinator, index):
"""Initialize the Mazda entity."""
super().__init__(coordinator)
self.client = client
self.index = index
self.vin = self.data["vin"]
self.vehicle_id = self.data["id"]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.vin)},
manufacturer="Mazda",
model=f"{self.data['modelYear']} {self.data['carlineName']}",
name=self.vehicle_name,
)
@property
def data(self):
"""Shortcut to access coordinator data for the entity."""
return self.coordinator.data[self.index]
@property
def vehicle_name(self):
"""Return the vehicle name, to be used as a prefix for names of other entities."""
if "nickname" in self.data and len(self.data["nickname"]) > 0:
return self.data["nickname"]
return f"{self.data['modelYear']} {self.data['carlineName']}"
return True

View File

@@ -1,131 +0,0 @@
"""Platform for Mazda binary sensor integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MazdaEntity
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
@dataclass
class MazdaBinarySensorRequiredKeysMixin:
"""Mixin for required keys."""
# Function to determine the value for this binary sensor, given the coordinator data
value_fn: Callable[[dict[str, Any]], bool]
@dataclass
class MazdaBinarySensorEntityDescription(
BinarySensorEntityDescription, MazdaBinarySensorRequiredKeysMixin
):
"""Describes a Mazda binary sensor entity."""
# Function to determine whether the vehicle supports this binary sensor, given the coordinator data
is_supported: Callable[[dict[str, Any]], bool] = lambda data: True
def _plugged_in_supported(data):
"""Determine if 'plugged in' binary sensor is supported."""
return (
data["isElectric"] and data["evStatus"]["chargeInfo"]["pluggedIn"] is not None
)
BINARY_SENSOR_ENTITIES = [
MazdaBinarySensorEntityDescription(
key="driver_door",
translation_key="driver_door",
icon="mdi:car-door",
device_class=BinarySensorDeviceClass.DOOR,
value_fn=lambda data: data["status"]["doors"]["driverDoorOpen"],
),
MazdaBinarySensorEntityDescription(
key="passenger_door",
translation_key="passenger_door",
icon="mdi:car-door",
device_class=BinarySensorDeviceClass.DOOR,
value_fn=lambda data: data["status"]["doors"]["passengerDoorOpen"],
),
MazdaBinarySensorEntityDescription(
key="rear_left_door",
translation_key="rear_left_door",
icon="mdi:car-door",
device_class=BinarySensorDeviceClass.DOOR,
value_fn=lambda data: data["status"]["doors"]["rearLeftDoorOpen"],
),
MazdaBinarySensorEntityDescription(
key="rear_right_door",
translation_key="rear_right_door",
icon="mdi:car-door",
device_class=BinarySensorDeviceClass.DOOR,
value_fn=lambda data: data["status"]["doors"]["rearRightDoorOpen"],
),
MazdaBinarySensorEntityDescription(
key="trunk",
translation_key="trunk",
icon="mdi:car-back",
device_class=BinarySensorDeviceClass.DOOR,
value_fn=lambda data: data["status"]["doors"]["trunkOpen"],
),
MazdaBinarySensorEntityDescription(
key="hood",
translation_key="hood",
icon="mdi:car",
device_class=BinarySensorDeviceClass.DOOR,
value_fn=lambda data: data["status"]["doors"]["hoodOpen"],
),
MazdaBinarySensorEntityDescription(
key="ev_plugged_in",
translation_key="ev_plugged_in",
device_class=BinarySensorDeviceClass.PLUG,
is_supported=_plugged_in_supported,
value_fn=lambda data: data["evStatus"]["chargeInfo"]["pluggedIn"],
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
async_add_entities(
MazdaBinarySensorEntity(client, coordinator, index, description)
for index, data in enumerate(coordinator.data)
for description in BINARY_SENSOR_ENTITIES
if description.is_supported(data)
)
class MazdaBinarySensorEntity(MazdaEntity, BinarySensorEntity):
"""Representation of a Mazda vehicle binary sensor."""
entity_description: MazdaBinarySensorEntityDescription
def __init__(self, client, coordinator, index, description):
"""Initialize Mazda binary sensor."""
super().__init__(client, coordinator, index)
self.entity_description = description
self._attr_unique_id = f"{self.vin}_{description.key}"
@property
def is_on(self):
"""Return the state of the binary sensor."""
return self.entity_description.value_fn(self.data)

View File

@@ -1,150 +0,0 @@
"""Platform for Mazda button integration."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from pymazda import (
Client as MazdaAPIClient,
MazdaAccountLockedException,
MazdaAPIEncryptionException,
MazdaAuthenticationException,
MazdaException,
MazdaLoginFailedException,
MazdaTokenExpiredException,
)
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import MazdaEntity
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
async def handle_button_press(
client: MazdaAPIClient,
key: str,
vehicle_id: int,
coordinator: DataUpdateCoordinator,
) -> None:
"""Handle a press for a Mazda button entity."""
api_method = getattr(client, key)
try:
await api_method(vehicle_id)
except (
MazdaException,
MazdaAuthenticationException,
MazdaAccountLockedException,
MazdaTokenExpiredException,
MazdaAPIEncryptionException,
MazdaLoginFailedException,
) as ex:
raise HomeAssistantError(ex) from ex
async def handle_refresh_vehicle_status(
client: MazdaAPIClient,
key: str,
vehicle_id: int,
coordinator: DataUpdateCoordinator,
) -> None:
"""Handle a request to refresh the vehicle status."""
await handle_button_press(client, key, vehicle_id, coordinator)
await coordinator.async_request_refresh()
@dataclass
class MazdaButtonEntityDescription(ButtonEntityDescription):
"""Describes a Mazda button entity."""
# Function to determine whether the vehicle supports this button,
# given the coordinator data
is_supported: Callable[[dict[str, Any]], bool] = lambda data: True
async_press: Callable[
[MazdaAPIClient, str, int, DataUpdateCoordinator], Awaitable
] = handle_button_press
BUTTON_ENTITIES = [
MazdaButtonEntityDescription(
key="start_engine",
translation_key="start_engine",
icon="mdi:engine",
is_supported=lambda data: not data["isElectric"],
),
MazdaButtonEntityDescription(
key="stop_engine",
translation_key="stop_engine",
icon="mdi:engine-off",
is_supported=lambda data: not data["isElectric"],
),
MazdaButtonEntityDescription(
key="turn_on_hazard_lights",
translation_key="turn_on_hazard_lights",
icon="mdi:hazard-lights",
is_supported=lambda data: not data["isElectric"],
),
MazdaButtonEntityDescription(
key="turn_off_hazard_lights",
translation_key="turn_off_hazard_lights",
icon="mdi:hazard-lights",
is_supported=lambda data: not data["isElectric"],
),
MazdaButtonEntityDescription(
key="refresh_vehicle_status",
translation_key="refresh_vehicle_status",
icon="mdi:refresh",
async_press=handle_refresh_vehicle_status,
is_supported=lambda data: data["isElectric"],
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the button platform."""
client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
async_add_entities(
MazdaButtonEntity(client, coordinator, index, description)
for index, data in enumerate(coordinator.data)
for description in BUTTON_ENTITIES
if description.is_supported(data)
)
class MazdaButtonEntity(MazdaEntity, ButtonEntity):
"""Representation of a Mazda button."""
entity_description: MazdaButtonEntityDescription
def __init__(
self,
client: MazdaAPIClient,
coordinator: DataUpdateCoordinator,
index: int,
description: MazdaButtonEntityDescription,
) -> None:
"""Initialize Mazda button."""
super().__init__(client, coordinator, index)
self.entity_description = description
self._attr_unique_id = f"{self.vin}_{description.key}"
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.async_press(
self.client, self.entity_description.key, self.vehicle_id, self.coordinator
)

View File

@@ -1,187 +0,0 @@
"""Platform for Mazda climate integration."""
from __future__ import annotations
from typing import Any
from pymazda import Client as MazdaAPIClient
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_WHOLE,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.unit_conversion import TemperatureConverter
from . import MazdaEntity
from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_REGION, DOMAIN
PRESET_DEFROSTER_OFF = "Defroster Off"
PRESET_DEFROSTER_FRONT = "Front Defroster"
PRESET_DEFROSTER_REAR = "Rear Defroster"
PRESET_DEFROSTER_FRONT_AND_REAR = "Front and Rear Defroster"
def _front_defroster_enabled(preset_mode: str | None) -> bool:
return preset_mode in [
PRESET_DEFROSTER_FRONT_AND_REAR,
PRESET_DEFROSTER_FRONT,
]
def _rear_defroster_enabled(preset_mode: str | None) -> bool:
return preset_mode in [
PRESET_DEFROSTER_FRONT_AND_REAR,
PRESET_DEFROSTER_REAR,
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the climate platform."""
entry_data = hass.data[DOMAIN][config_entry.entry_id]
client = entry_data[DATA_CLIENT]
coordinator = entry_data[DATA_COORDINATOR]
region = entry_data[DATA_REGION]
async_add_entities(
MazdaClimateEntity(client, coordinator, index, region)
for index, data in enumerate(coordinator.data)
if data["isElectric"]
)
class MazdaClimateEntity(MazdaEntity, ClimateEntity):
"""Class for a Mazda climate entity."""
_attr_translation_key = "climate"
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
_attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF]
_attr_preset_modes = [
PRESET_DEFROSTER_OFF,
PRESET_DEFROSTER_FRONT,
PRESET_DEFROSTER_REAR,
PRESET_DEFROSTER_FRONT_AND_REAR,
]
def __init__(
self,
client: MazdaAPIClient,
coordinator: DataUpdateCoordinator,
index: int,
region: str,
) -> None:
"""Initialize Mazda climate entity."""
super().__init__(client, coordinator, index)
self.region = region
self._attr_unique_id = self.vin
if self.data["hvacSetting"]["temperatureUnit"] == "F":
self._attr_precision = PRECISION_WHOLE
self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
self._attr_min_temp = 61.0
self._attr_max_temp = 83.0
else:
self._attr_precision = PRECISION_HALVES
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
if region == "MJO":
self._attr_min_temp = 18.5
self._attr_max_temp = 31.5
else:
self._attr_min_temp = 15.5
self._attr_max_temp = 28.5
self._update_state_attributes()
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator data updates."""
self._update_state_attributes()
super()._handle_coordinator_update()
def _update_state_attributes(self) -> None:
# Update the HVAC mode
hvac_on = self.client.get_assumed_hvac_mode(self.vehicle_id)
self._attr_hvac_mode = HVACMode.HEAT_COOL if hvac_on else HVACMode.OFF
# Update the target temperature
hvac_setting = self.client.get_assumed_hvac_setting(self.vehicle_id)
self._attr_target_temperature = hvac_setting.get("temperature")
# Update the current temperature
current_temperature_celsius = self.data["evStatus"]["hvacInfo"][
"interiorTemperatureCelsius"
]
if self.data["hvacSetting"]["temperatureUnit"] == "F":
self._attr_current_temperature = TemperatureConverter.convert(
current_temperature_celsius,
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
else:
self._attr_current_temperature = current_temperature_celsius
# Update the preset mode based on the state of the front and rear defrosters
front_defroster = hvac_setting.get("frontDefroster")
rear_defroster = hvac_setting.get("rearDefroster")
if front_defroster and rear_defroster:
self._attr_preset_mode = PRESET_DEFROSTER_FRONT_AND_REAR
elif front_defroster:
self._attr_preset_mode = PRESET_DEFROSTER_FRONT
elif rear_defroster:
self._attr_preset_mode = PRESET_DEFROSTER_REAR
else:
self._attr_preset_mode = PRESET_DEFROSTER_OFF
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set a new HVAC mode."""
if hvac_mode == HVACMode.HEAT_COOL:
await self.client.turn_on_hvac(self.vehicle_id)
elif hvac_mode == HVACMode.OFF:
await self.client.turn_off_hvac(self.vehicle_id)
self._handle_coordinator_update()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
precision = self.precision
rounded_temperature = round(temperature / precision) * precision
await self.client.set_hvac_setting(
self.vehicle_id,
rounded_temperature,
self.data["hvacSetting"]["temperatureUnit"],
_front_defroster_enabled(self._attr_preset_mode),
_rear_defroster_enabled(self._attr_preset_mode),
)
self._handle_coordinator_update()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Turn on/off the front/rear defrosters according to the chosen preset mode."""
await self.client.set_hvac_setting(
self.vehicle_id,
self._attr_target_temperature,
self.data["hvacSetting"]["temperatureUnit"],
_front_defroster_enabled(preset_mode),
_rear_defroster_enabled(preset_mode),
)
self._handle_coordinator_update()

View File

@@ -1,110 +1,11 @@
"""Config flow for Mazda Connected Services integration."""
from collections.abc import Mapping
import logging
from typing import Any
"""The Mazda Connected Services integration."""
import aiohttp
from pymazda import (
Client as MazdaAPI,
MazdaAccountLockedException,
MazdaAuthenticationException,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant import config_entries
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN, MAZDA_REGIONS
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION): vol.In(MAZDA_REGIONS),
}
)
from . import DOMAIN
class MazdaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class MazdaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Mazda Connected Services."""
VERSION = 1
def __init__(self):
"""Start the mazda config flow."""
self._reauth_entry = None
self._email = None
self._region = None
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
self._email = user_input[CONF_EMAIL]
self._region = user_input[CONF_REGION]
unique_id = user_input[CONF_EMAIL].lower()
await self.async_set_unique_id(unique_id)
if not self._reauth_entry:
self._abort_if_unique_id_configured()
websession = aiohttp_client.async_get_clientsession(self.hass)
mazda_client = MazdaAPI(
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
user_input[CONF_REGION],
websession,
)
try:
await mazda_client.validate_credentials()
except MazdaAuthenticationException:
errors["base"] = "invalid_auth"
except MazdaAccountLockedException:
errors["base"] = "account_locked"
except aiohttp.ClientError:
errors["base"] = "cannot_connect"
except Exception as ex: # pylint: disable=broad-except
errors["base"] = "unknown"
_LOGGER.exception(
"Unknown error occurred during Mazda login request: %s", ex
)
else:
if not self._reauth_entry:
return self.async_create_entry(
title=user_input[CONF_EMAIL], data=user_input
)
self.hass.config_entries.async_update_entry(
self._reauth_entry, data=user_input, unique_id=unique_id
)
# Reload the config entry otherwise devices will remain unavailable
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_EMAIL, default=self._email): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION, default=self._region): vol.In(
MAZDA_REGIONS
),
}
),
errors=errors,
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth if the user credentials have changed."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
self._email = entry_data[CONF_EMAIL]
self._region = entry_data[CONF_REGION]
return await self.async_step_user()

View File

@@ -1,10 +0,0 @@
"""Constants for the Mazda Connected Services integration."""
DOMAIN = "mazda"
DATA_CLIENT = "mazda_client"
DATA_COORDINATOR = "coordinator"
DATA_REGION = "region"
DATA_VEHICLES = "vehicles"
MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"}

View File

@@ -1,54 +0,0 @@
"""Platform for Mazda device tracker integration."""
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MazdaEntity
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the device tracker platform."""
client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
entities = []
for index, _ in enumerate(coordinator.data):
entities.append(MazdaDeviceTracker(client, coordinator, index))
async_add_entities(entities)
class MazdaDeviceTracker(MazdaEntity, TrackerEntity):
"""Class for the device tracker."""
_attr_translation_key = "device_tracker"
_attr_icon = "mdi:car"
_attr_force_update = False
def __init__(self, client, coordinator, index) -> None:
"""Initialize Mazda device tracker."""
super().__init__(client, coordinator, index)
self._attr_unique_id = self.vin
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
return SourceType.GPS
@property
def latitude(self):
"""Return latitude value of the device."""
return self.data["status"]["latitude"]
@property
def longitude(self):
"""Return longitude value of the device."""
return self.data["status"]["longitude"]

View File

@@ -1,57 +0,0 @@
"""Diagnostics support for the Mazda integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics.util import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DATA_COORDINATOR, DOMAIN
TO_REDACT_INFO = [CONF_EMAIL, CONF_PASSWORD]
TO_REDACT_DATA = ["vin", "id", "latitude", "longitude"]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
diagnostics_data = {
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
"data": [
async_redact_data(vehicle, TO_REDACT_DATA) for vehicle in coordinator.data
],
}
return diagnostics_data
async def async_get_device_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
vin = next(iter(device.identifiers))[1]
target_vehicle = None
for vehicle in coordinator.data:
if vehicle["vin"] == vin:
target_vehicle = vehicle
break
if target_vehicle is None:
raise HomeAssistantError("Vehicle not found")
diagnostics_data = {
"info": async_redact_data(config_entry.data, TO_REDACT_INFO),
"data": async_redact_data(target_vehicle, TO_REDACT_DATA),
}
return diagnostics_data

View File

@@ -1,58 +0,0 @@
"""Platform for Mazda lock integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MazdaEntity
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the lock platform."""
client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
entities = []
for index, _ in enumerate(coordinator.data):
entities.append(MazdaLock(client, coordinator, index))
async_add_entities(entities)
class MazdaLock(MazdaEntity, LockEntity):
"""Class for the lock."""
_attr_translation_key = "lock"
def __init__(self, client, coordinator, index) -> None:
"""Initialize Mazda lock."""
super().__init__(client, coordinator, index)
self._attr_unique_id = self.vin
@property
def is_locked(self) -> bool | None:
"""Return true if lock is locked."""
return self.client.get_assumed_lock_state(self.vehicle_id)
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the vehicle doors."""
await self.client.lock_doors(self.vehicle_id)
self.async_write_ha_state()
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the vehicle doors."""
await self.client.unlock_doors(self.vehicle_id)
self.async_write_ha_state()

View File

@@ -1,11 +1,9 @@
{
"domain": "mazda",
"name": "Mazda Connected Services",
"codeowners": ["@bdr99"],
"config_flow": true,
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/mazda",
"integration_type": "system",
"iot_class": "cloud_polling",
"loggers": ["pymazda"],
"quality_scale": "platinum",
"requirements": ["pymazda==0.3.11"]
"requirements": []
}

View File

@@ -1,263 +0,0 @@
"""Platform for Mazda sensor integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import MazdaEntity
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
@dataclass
class MazdaSensorRequiredKeysMixin:
"""Mixin for required keys."""
# Function to determine the value for this sensor, given the coordinator data
# and the configured unit system
value: Callable[[dict[str, Any]], StateType]
@dataclass
class MazdaSensorEntityDescription(
SensorEntityDescription, MazdaSensorRequiredKeysMixin
):
"""Describes a Mazda sensor entity."""
# Function to determine whether the vehicle supports this sensor,
# given the coordinator data
is_supported: Callable[[dict[str, Any]], bool] = lambda data: True
def _fuel_remaining_percentage_supported(data):
"""Determine if fuel remaining percentage is supported."""
return (not data["isElectric"]) and (
data["status"]["fuelRemainingPercent"] is not None
)
def _fuel_distance_remaining_supported(data):
"""Determine if fuel distance remaining is supported."""
return (not data["isElectric"]) and (
data["status"]["fuelDistanceRemainingKm"] is not None
)
def _front_left_tire_pressure_supported(data):
"""Determine if front left tire pressure is supported."""
return data["status"]["tirePressure"]["frontLeftTirePressurePsi"] is not None
def _front_right_tire_pressure_supported(data):
"""Determine if front right tire pressure is supported."""
return data["status"]["tirePressure"]["frontRightTirePressurePsi"] is not None
def _rear_left_tire_pressure_supported(data):
"""Determine if rear left tire pressure is supported."""
return data["status"]["tirePressure"]["rearLeftTirePressurePsi"] is not None
def _rear_right_tire_pressure_supported(data):
"""Determine if rear right tire pressure is supported."""
return data["status"]["tirePressure"]["rearRightTirePressurePsi"] is not None
def _ev_charge_level_supported(data):
"""Determine if charge level is supported."""
return (
data["isElectric"]
and data["evStatus"]["chargeInfo"]["batteryLevelPercentage"] is not None
)
def _ev_remaining_range_supported(data):
"""Determine if remaining range is supported."""
return (
data["isElectric"]
and data["evStatus"]["chargeInfo"]["drivingRangeKm"] is not None
)
def _fuel_distance_remaining_value(data):
"""Get the fuel distance remaining value."""
return round(data["status"]["fuelDistanceRemainingKm"])
def _odometer_value(data):
"""Get the odometer value."""
# In order to match the behavior of the Mazda mobile app, we always round down
return int(data["status"]["odometerKm"])
def _front_left_tire_pressure_value(data):
"""Get the front left tire pressure value."""
return round(data["status"]["tirePressure"]["frontLeftTirePressurePsi"])
def _front_right_tire_pressure_value(data):
"""Get the front right tire pressure value."""
return round(data["status"]["tirePressure"]["frontRightTirePressurePsi"])
def _rear_left_tire_pressure_value(data):
"""Get the rear left tire pressure value."""
return round(data["status"]["tirePressure"]["rearLeftTirePressurePsi"])
def _rear_right_tire_pressure_value(data):
"""Get the rear right tire pressure value."""
return round(data["status"]["tirePressure"]["rearRightTirePressurePsi"])
def _ev_charge_level_value(data):
"""Get the charge level value."""
return round(data["evStatus"]["chargeInfo"]["batteryLevelPercentage"])
def _ev_remaining_range_value(data):
"""Get the remaining range value."""
return round(data["evStatus"]["chargeInfo"]["drivingRangeKm"])
SENSOR_ENTITIES = [
MazdaSensorEntityDescription(
key="fuel_remaining_percentage",
translation_key="fuel_remaining_percentage",
icon="mdi:gas-station",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_fuel_remaining_percentage_supported,
value=lambda data: data["status"]["fuelRemainingPercent"],
),
MazdaSensorEntityDescription(
key="fuel_distance_remaining",
translation_key="fuel_distance_remaining",
icon="mdi:gas-station",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_fuel_distance_remaining_supported,
value=_fuel_distance_remaining_value,
),
MazdaSensorEntityDescription(
key="odometer",
translation_key="odometer",
icon="mdi:speedometer",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.TOTAL_INCREASING,
is_supported=lambda data: data["status"]["odometerKm"] is not None,
value=_odometer_value,
),
MazdaSensorEntityDescription(
key="front_left_tire_pressure",
translation_key="front_left_tire_pressure",
icon="mdi:car-tire-alert",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_front_left_tire_pressure_supported,
value=_front_left_tire_pressure_value,
),
MazdaSensorEntityDescription(
key="front_right_tire_pressure",
translation_key="front_right_tire_pressure",
icon="mdi:car-tire-alert",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_front_right_tire_pressure_supported,
value=_front_right_tire_pressure_value,
),
MazdaSensorEntityDescription(
key="rear_left_tire_pressure",
translation_key="rear_left_tire_pressure",
icon="mdi:car-tire-alert",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_rear_left_tire_pressure_supported,
value=_rear_left_tire_pressure_value,
),
MazdaSensorEntityDescription(
key="rear_right_tire_pressure",
translation_key="rear_right_tire_pressure",
icon="mdi:car-tire-alert",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.PSI,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_rear_right_tire_pressure_supported,
value=_rear_right_tire_pressure_value,
),
MazdaSensorEntityDescription(
key="ev_charge_level",
translation_key="ev_charge_level",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_ev_charge_level_supported,
value=_ev_charge_level_value,
),
MazdaSensorEntityDescription(
key="ev_remaining_range",
translation_key="ev_remaining_range",
icon="mdi:ev-station",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
is_supported=_ev_remaining_range_supported,
value=_ev_remaining_range_value,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
entities: list[SensorEntity] = []
for index, data in enumerate(coordinator.data):
for description in SENSOR_ENTITIES:
if description.is_supported(data):
entities.append(
MazdaSensorEntity(client, coordinator, index, description)
)
async_add_entities(entities)
class MazdaSensorEntity(MazdaEntity, SensorEntity):
"""Representation of a Mazda vehicle sensor."""
entity_description: MazdaSensorEntityDescription
def __init__(self, client, coordinator, index, description):
"""Initialize Mazda sensor."""
super().__init__(client, coordinator, index)
self.entity_description = description
self._attr_unique_id = f"{self.vin}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value(self.data)

View File

@@ -1,30 +0,0 @@
send_poi:
fields:
device_id:
required: true
selector:
device:
integration: mazda
latitude:
example: 12.34567
required: true
selector:
number:
min: -90
max: 90
unit_of_measurement: °
mode: box
longitude:
example: -34.56789
required: true
selector:
number:
min: -180
max: 180
unit_of_measurement: °
mode: box
poi_name:
example: Work
required: true
selector:
text:

View File

@@ -1,139 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"account_locked": "Account locked. Please try again later.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]",
"region": "Region"
},
"description": "Please enter the email address and password you use to log into the MyMazda mobile app."
}
}
},
"entity": {
"binary_sensor": {
"driver_door": {
"name": "Driver door"
},
"passenger_door": {
"name": "Passenger door"
},
"rear_left_door": {
"name": "Rear left door"
},
"rear_right_door": {
"name": "Rear right door"
},
"trunk": {
"name": "Trunk"
},
"hood": {
"name": "Hood"
},
"ev_plugged_in": {
"name": "Plugged in"
}
},
"button": {
"start_engine": {
"name": "Start engine"
},
"stop_engine": {
"name": "Stop engine"
},
"turn_on_hazard_lights": {
"name": "Turn on hazard lights"
},
"turn_off_hazard_lights": {
"name": "Turn off hazard lights"
},
"refresh_vehicle_status": {
"name": "Refresh status"
}
},
"climate": {
"climate": {
"name": "[%key:component::climate::title%]"
}
},
"device_tracker": {
"device_tracker": {
"name": "[%key:component::device_tracker::title%]"
}
},
"lock": {
"lock": {
"name": "[%key:component::lock::title%]"
}
},
"sensor": {
"fuel_remaining_percentage": {
"name": "Fuel remaining percentage"
},
"fuel_distance_remaining": {
"name": "Fuel distance remaining"
},
"odometer": {
"name": "Odometer"
},
"front_left_tire_pressure": {
"name": "Front left tire pressure"
},
"front_right_tire_pressure": {
"name": "Front right tire pressure"
},
"rear_left_tire_pressure": {
"name": "Rear left tire pressure"
},
"rear_right_tire_pressure": {
"name": "Rear right tire pressure"
},
"ev_charge_level": {
"name": "Charge level"
},
"ev_remaining_range": {
"name": "Remaining range"
}
},
"switch": {
"charging": {
"name": "Charging"
}
}
},
"services": {
"send_poi": {
"name": "Send POI",
"description": "Sends a GPS location to the vehicle's navigation system as a POI (Point of Interest). Requires a navigation SD card installed in the vehicle.",
"fields": {
"device_id": {
"name": "Vehicle",
"description": "The vehicle to send the GPS location to."
},
"latitude": {
"name": "[%key:common::config_flow::data::latitude%]",
"description": "The latitude of the location to send."
},
"longitude": {
"name": "[%key:common::config_flow::data::longitude%]",
"description": "The longitude of the location to send."
},
"poi_name": {
"name": "POI name",
"description": "A friendly name for the location."
}
}
"issues": {
"integration_removed": {
"title": "The Mazda integration has been removed",
"description": "The Mazda integration has been removed from Home Assistant.\n\nThe library that Home Assistant uses to connect with their services, [has been taken offline by Mazda]({dmca}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Mazda integration entries]({entries})."
}
}
}

View File

@@ -1,72 +0,0 @@
"""Platform for Mazda switch integration."""
from typing import Any
from pymazda import Client as MazdaAPIClient
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import MazdaEntity
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the switch platform."""
client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
async_add_entities(
MazdaChargingSwitch(client, coordinator, index)
for index, data in enumerate(coordinator.data)
if data["isElectric"]
)
class MazdaChargingSwitch(MazdaEntity, SwitchEntity):
"""Class for the charging switch."""
_attr_translation_key = "charging"
_attr_icon = "mdi:ev-station"
def __init__(
self,
client: MazdaAPIClient,
coordinator: DataUpdateCoordinator,
index: int,
) -> None:
"""Initialize Mazda charging switch."""
super().__init__(client, coordinator, index)
self._attr_unique_id = self.vin
@property
def is_on(self):
"""Return true if the vehicle is charging."""
return self.data["evStatus"]["chargeInfo"]["charging"]
async def refresh_status_and_write_state(self):
"""Request a status update, retrieve it through the coordinator, and write the state."""
await self.client.refresh_vehicle_status(self.vehicle_id)
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start charging the vehicle."""
await self.client.start_charging(self.vehicle_id)
await self.refresh_status_and_write_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop charging the vehicle."""
await self.client.stop_charging(self.vehicle_id)
await self.refresh_status_and_write_state()

View File

@@ -84,7 +84,7 @@ DEFAULT_STRUCT_FORMAT = {
DataType.INT64: ENTRY("q", 4, PARM_IS_LEGAL(False, False, True, True, True)),
DataType.UINT64: ENTRY("Q", 4, PARM_IS_LEGAL(False, False, True, True, True)),
DataType.FLOAT64: ENTRY("d", 4, PARM_IS_LEGAL(False, False, True, True, True)),
DataType.STRING: ENTRY("s", 1, PARM_IS_LEGAL(True, False, False, False, False)),
DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, False, False)),
DataType.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)),
}
@@ -143,7 +143,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]:
f"{name}: Size of structure is {size} bytes but `{CONF_COUNT}: {count}` is {bytecount} bytes"
)
else:
config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count
if data_type != DataType.STRING:
config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count
if slave_count:
structure = (
f">{slave_count + 1}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}"

View File

@@ -180,7 +180,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity):
@callback
@log_messages(self.hass, self.entity_id)
@write_state_on_attr_change(self, {"_attr_is_on"})
@write_state_on_attr_change(self, {"_attr_is_on", "_expired"})
def state_message_received(msg: ReceiveMessage) -> None:
"""Handle a new received MQTT state message."""
# auto-expire enabled?

View File

@@ -52,7 +52,7 @@ DEFAULT_SOURCE_TYPE = SourceType.GPS
def valid_config(config: ConfigType) -> ConfigType:
"""Check if there is a state topic or json_attributes_topic."""
if CONF_STATE_TOPIC not in config and CONF_JSON_ATTRS_TOPIC not in config:
raise vol.MultipleInvalid(
raise vol.Invalid(
f"Invalid device tracker config, missing {CONF_STATE_TOPIC} or {CONF_JSON_ATTRS_TOPIC}, got: {config}"
)
return config

View File

@@ -277,7 +277,9 @@ class MqttSensor(MqttEntity, RestoreSensor):
)
@callback
@write_state_on_attr_change(self, {"_attr_native_value", "_attr_last_reset"})
@write_state_on_attr_change(
self, {"_attr_native_value", "_attr_last_reset", "_expired"}
)
@log_messages(self.hass, self.entity_id)
def message_received(msg: ReceiveMessage) -> None:
"""Handle new MQTT messages."""

View File

@@ -14,5 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["pkce", "pymyq"],
"requirements": ["python-myq==3.1.11"]
"requirements": ["python-myq==3.1.13"]
}

View File

@@ -8,7 +8,7 @@ from typing import Any
from mysensors import BaseAsyncGateway, Sensor
from mysensors.sensor import ChildSensor
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
@@ -212,6 +212,8 @@ class MySensorsChildEntity(MySensorNodeEntity):
attr[ATTR_CHILD_ID] = self.child_id
attr[ATTR_DESCRIPTION] = self._child.description
# We should deprecate the battery level attribute in the future.
attr[ATTR_BATTERY_LEVEL] = self._node.battery_level
set_req = self.gateway.const.SetReq
for value_type, value in self._values.items():

View File

@@ -58,7 +58,7 @@ def _get_stop_tags(
# Append directions for stops with shared titles
for tag, title in tags.items():
if title_counts[title] > 1:
tags[tag] = f"{title} ({stop_directions[tag]})"
tags[tag] = f"{title} ({stop_directions.get(tag, tag)})"
return tags

View File

@@ -1,7 +1,7 @@
{
"services": {
"submit_movie_request": {
"name": "Sumbit movie request",
"name": "Submit movie request",
"description": "Searches for a movie and requests the first result.",
"fields": {
"name": {

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/philips_js",
"iot_class": "local_polling",
"loggers": ["haphilipsjs"],
"requirements": ["ha-philipsjs==3.1.0"]
"requirements": ["ha-philipsjs==3.1.1"]
}

View File

@@ -259,7 +259,7 @@
"name": "DHW comfort mode"
},
"lock": {
"name": "[%key:component::lock::entity_component::_::name%]"
"name": "[%key:component::lock::title%]"
},
"relay": {
"name": "Relay"

View File

@@ -277,7 +277,8 @@ class MinutPointEntity(Entity):
sw_version=device["firmware"]["installed"],
via_device=(DOMAIN, device["home"]),
)
self._attr_name = f"{self._name} {device_class.capitalize()}"
if device_class:
self._attr_name = f"{self._name} {device_class.capitalize()}"
def __str__(self):
"""Return string representation of device."""

View File

@@ -3,5 +3,5 @@
"name": "Camera Proxy",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/proxy",
"requirements": ["Pillow==10.0.0"]
"requirements": ["Pillow==10.0.1"]
}

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
"iot_class": "local_polling",
"loggers": ["aioqsw"],
"requirements": ["aioqsw==0.3.4"]
"requirements": ["aioqsw==0.3.5"]
}

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/qrcode",
"iot_class": "calculated",
"loggers": ["pyzbar"],
"requirements": ["Pillow==10.0.0", "pyzbar==0.1.7"]
"requirements": ["Pillow==10.0.1", "pyzbar==0.1.7"]
}

View File

@@ -48,7 +48,7 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorE
"""Initialize the Rain Bird sensor."""
super().__init__(coordinator)
self.entity_description = description
if coordinator.unique_id:
if coordinator.unique_id is not None:
self._attr_unique_id = f"{coordinator.unique_id}-{description.key}"
self._attr_device_info = coordinator.device_info
else:

View File

@@ -61,7 +61,7 @@ class RainBirdCalendarEntity(
"""Create the Calendar event device."""
super().__init__(coordinator)
self._event: CalendarEvent | None = None
if unique_id:
if unique_id is not None:
self._attr_unique_id = unique_id
self._attr_device_info = device_info
else:

View File

@@ -84,7 +84,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]):
@property
def device_info(self) -> DeviceInfo | None:
"""Return information about the device."""
if not self._unique_id:
if self._unique_id is None:
return None
return DeviceInfo(
name=self.device_name,

View File

@@ -51,7 +51,7 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity
) -> None:
"""Initialize the Rain Bird sensor."""
super().__init__(coordinator)
if coordinator.unique_id:
if coordinator.unique_id is not None:
self._attr_unique_id = f"{coordinator.unique_id}-rain-delay"
self._attr_device_info = coordinator.device_info
else:

View File

@@ -52,7 +52,7 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity)
"""Initialize the Rain Bird sensor."""
super().__init__(coordinator)
self.entity_description = description
if coordinator.unique_id:
if coordinator.unique_id is not None:
self._attr_unique_id = f"{coordinator.unique_id}-{description.key}"
self._attr_device_info = coordinator.device_info
else:

View File

@@ -65,17 +65,18 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity)
"""Initialize a Rain Bird Switch Device."""
super().__init__(coordinator)
self._zone = zone
if coordinator.unique_id:
_LOGGER.debug("coordinator.unique_id=%s", coordinator.unique_id)
if coordinator.unique_id is not None:
self._attr_unique_id = f"{coordinator.unique_id}-{zone}"
device_name = f"{MANUFACTURER} Sprinkler {zone}"
if imported_name:
self._attr_name = imported_name
self._attr_has_entity_name = False
else:
self._attr_name = None if coordinator.unique_id else device_name
self._attr_name = None if coordinator.unique_id is not None else device_name
self._attr_has_entity_name = True
self._duration_minutes = duration_minutes
if coordinator.unique_id and self._attr_unique_id:
if coordinator.unique_id is not None and self._attr_unique_id is not None:
self._attr_device_info = DeviceInfo(
name=device_name,
identifiers={(DOMAIN, self._attr_unique_id)},

View File

@@ -526,7 +526,7 @@ def _compile_statistics(
):
continue
compiled: PlatformCompiledStatistics = platform_compile_statistics(
instance.hass, start, end
instance.hass, session, start, end
)
_LOGGER.debug(
"Statistics for %s during %s-%s: %s",
@@ -1871,7 +1871,7 @@ def get_latest_short_term_statistics_by_ids(
return list(
cast(
Sequence[Row],
execute_stmt_lambda_element(session, stmt, orm_rows=False),
execute_stmt_lambda_element(session, stmt),
)
)
@@ -1887,69 +1887,69 @@ def _latest_short_term_statistics_by_ids_stmt(
)
def get_latest_short_term_statistics(
def get_latest_short_term_statistics_with_session(
hass: HomeAssistant,
session: Session,
statistic_ids: set[str],
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
metadata: dict[str, tuple[int, StatisticMetaData]] | None = None,
) -> dict[str, list[StatisticsRow]]:
"""Return the latest short term statistics for a list of statistic_ids."""
with session_scope(hass=hass, read_only=True) as session:
# Fetch metadata for the given statistic_ids
if not metadata:
metadata = get_instance(hass).statistics_meta_manager.get_many(
session, statistic_ids=statistic_ids
)
if not metadata:
return {}
metadata_ids = set(
_extract_metadata_and_discard_impossible_columns(metadata, types)
"""Return the latest short term statistics for a list of statistic_ids with a session."""
# Fetch metadata for the given statistic_ids
if not metadata:
metadata = get_instance(hass).statistics_meta_manager.get_many(
session, statistic_ids=statistic_ids
)
run_cache = get_short_term_statistics_run_cache(hass)
# Try to find the latest short term statistics ids for the metadata_ids
# from the run cache first if we have it. If the run cache references
# a non-existent id because of a purge, we will detect it missing in the
# next step and run a query to re-populate the cache.
stats: list[Row] = []
if metadata_id_to_id := run_cache.get_latest_ids(metadata_ids):
stats = get_latest_short_term_statistics_by_ids(
session, metadata_id_to_id.values()
)
# If we are missing some metadata_ids in the run cache, we need run a query
# to populate the cache for each metadata_id, and then run another query
# to get the latest short term statistics for the missing metadata_ids.
if (missing_metadata_ids := metadata_ids - set(metadata_id_to_id)) and (
found_latest_ids := {
latest_id
for metadata_id in missing_metadata_ids
if (
latest_id := cache_latest_short_term_statistic_id_for_metadata_id(
run_cache, session, metadata_id
)
if not metadata:
return {}
metadata_ids = set(
_extract_metadata_and_discard_impossible_columns(metadata, types)
)
run_cache = get_short_term_statistics_run_cache(hass)
# Try to find the latest short term statistics ids for the metadata_ids
# from the run cache first if we have it. If the run cache references
# a non-existent id because of a purge, we will detect it missing in the
# next step and run a query to re-populate the cache.
stats: list[Row] = []
if metadata_id_to_id := run_cache.get_latest_ids(metadata_ids):
stats = get_latest_short_term_statistics_by_ids(
session, metadata_id_to_id.values()
)
# If we are missing some metadata_ids in the run cache, we need run a query
# to populate the cache for each metadata_id, and then run another query
# to get the latest short term statistics for the missing metadata_ids.
if (missing_metadata_ids := metadata_ids - set(metadata_id_to_id)) and (
found_latest_ids := {
latest_id
for metadata_id in missing_metadata_ids
if (
latest_id := cache_latest_short_term_statistic_id_for_metadata_id(
run_cache,
session,
metadata_id,
)
is not None
}
):
stats.extend(
get_latest_short_term_statistics_by_ids(session, found_latest_ids)
)
is not None
}
):
stats.extend(get_latest_short_term_statistics_by_ids(session, found_latest_ids))
if not stats:
return {}
if not stats:
return {}
# Return statistics combined with metadata
return _sorted_statistics_to_dict(
hass,
session,
stats,
statistic_ids,
metadata,
False,
StatisticsShortTerm,
None,
None,
types,
)
# Return statistics combined with metadata
return _sorted_statistics_to_dict(
hass,
session,
stats,
statistic_ids,
metadata,
False,
StatisticsShortTerm,
None,
None,
types,
)
def _generate_statistics_at_time_stmt(
@@ -2326,7 +2326,9 @@ def get_short_term_statistics_run_cache(
def cache_latest_short_term_statistic_id_for_metadata_id(
run_cache: ShortTermStatisticsRunCache, session: Session, metadata_id: int
run_cache: ShortTermStatisticsRunCache,
session: Session,
metadata_id: int,
) -> int | None:
"""Cache the latest short term statistic for a given metadata_id.
@@ -2337,9 +2339,7 @@ def cache_latest_short_term_statistic_id_for_metadata_id(
if latest := cast(
Sequence[Row],
execute_stmt_lambda_element(
session,
_find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id),
orm_rows=False,
session, _find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id)
),
):
id_: int = latest[0].id

View File

@@ -16,7 +16,7 @@
},
"complete_task": {
"name": "Complete task",
"description": "Completes a tasks that was privously created.",
"description": "Completes a task that was previously created.",
"fields": {
"id": {
"name": "ID",

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import datetime
from roborock.containers import (
RoborockDockErrorCode,
@@ -38,7 +39,7 @@ from .device import RoborockCoordinatedEntity
class RoborockSensorDescriptionMixin:
"""A class that describes sensor entities."""
value_fn: Callable[[DeviceProp], int]
value_fn: Callable[[DeviceProp], StateType | datetime.datetime]
@dataclass
@@ -48,6 +49,15 @@ class RoborockSensorDescription(
"""A class that describes Roborock sensors."""
def _dock_error_value_fn(properties: DeviceProp) -> str | None:
if (
status := properties.status.dock_error_status
) is not None and properties.status.dock_type != RoborockDockTypeCode.no_dock:
return status.name
return None
SENSOR_DESCRIPTIONS = [
RoborockSensorDescription(
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -173,9 +183,7 @@ SENSOR_DESCRIPTIONS = [
key="dock_error",
icon="mdi:garage-open",
translation_key="dock_error",
value_fn=lambda data: data.status.dock_error_status.name
if data.status.dock_type != RoborockDockTypeCode.no_dock
else None,
value_fn=_dock_error_value_fn,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=RoborockDockErrorCode.keys(),
@@ -228,7 +236,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity):
self.entity_description = description
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime.datetime:
"""Return the value reported by the sensor."""
return self.entity_description.value_fn(
self.coordinator.roborock_device_info.props

View File

@@ -92,7 +92,6 @@ class RoonDevice(MediaPlayerEntity):
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
@@ -104,7 +103,6 @@ class RoonDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
)
def __init__(self, server, player_data):
@@ -124,6 +122,8 @@ class RoonDevice(MediaPlayerEntity):
self._attr_shuffle = False
self._attr_media_image_url = None
self._attr_volume_level = 0
self._volume_fixed = True
self._volume_incremental = False
self.update_data(player_data)
async def async_added_to_hass(self) -> None:
@@ -190,12 +190,21 @@ class RoonDevice(MediaPlayerEntity):
"level": 0,
"step": 0,
"muted": False,
"fixed": True,
"incremental": False,
}
try:
volume_data = player_data["volume"]
volume_muted = volume_data["is_muted"]
volume_step = convert(volume_data["step"], int, 0)
except KeyError:
return volume
volume["fixed"] = False
volume["incremental"] = volume_data["type"] == "incremental"
volume["muted"] = volume_data.get("is_muted", False)
volume["step"] = convert(volume_data.get("step"), int, 0)
try:
volume_max = volume_data["max"]
volume_min = volume_data["min"]
raw_level = convert(volume_data["value"], float, 0)
@@ -204,15 +213,9 @@ class RoonDevice(MediaPlayerEntity):
volume_percentage_factor = volume_range / 100
level = (raw_level - volume_min) / volume_percentage_factor
volume_level = convert(level, int, 0) / 100
volume["level"] = convert(level, int, 0) / 100
except KeyError:
# catch KeyError
pass
else:
volume["muted"] = volume_muted
volume["step"] = volume_step
volume["level"] = volume_level
return volume
@@ -288,6 +291,16 @@ class RoonDevice(MediaPlayerEntity):
self._attr_is_volume_muted = volume["muted"]
self._attr_volume_step = volume["step"]
self._attr_volume_level = volume["level"]
self._volume_fixed = volume["fixed"]
self._volume_incremental = volume["incremental"]
if not self._volume_fixed:
self._attr_supported_features = (
self._attr_supported_features | MediaPlayerEntityFeature.VOLUME_STEP
)
if not self._volume_incremental:
self._attr_supported_features = (
self._attr_supported_features | MediaPlayerEntityFeature.VOLUME_SET
)
now_playing = self._parse_now_playing(self.player_data)
self._attr_media_title = now_playing["title"]
@@ -359,11 +372,17 @@ class RoonDevice(MediaPlayerEntity):
def volume_up(self) -> None:
"""Send new volume_level to device."""
self._server.roonapi.change_volume_percent(self.output_id, 3)
if self._volume_incremental:
self._server.roonapi.change_volume_raw(self.output_id, 1, "relative_step")
else:
self._server.roonapi.change_volume_percent(self.output_id, 3)
def volume_down(self) -> None:
"""Send new volume_level to device."""
self._server.roonapi.change_volume_percent(self.output_id, -3)
if self._volume_incremental:
self._server.roonapi.change_volume_raw(self.output_id, -1, "relative_step")
else:
self._server.roonapi.change_volume_percent(self.output_id, -3)
def turn_on(self) -> None:
"""Turn on device (if supported)."""

View File

@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/screenlogic",
"iot_class": "local_push",
"loggers": ["screenlogicpy"],
"requirements": ["screenlogicpy==0.9.1"]
"requirements": ["screenlogicpy==0.9.2"]
}

View File

@@ -1,5 +1,6 @@
"""Support for a ScreenLogic number entity."""
from collections.abc import Callable
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
@@ -105,13 +106,13 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity):
) -> None:
"""Initialize a ScreenLogic number entity."""
super().__init__(coordinator, entity_description)
if not callable(
if not asyncio.iscoroutinefunction(
func := getattr(self.gateway, entity_description.set_value_name)
):
raise TypeError(
f"set_value_name '{entity_description.set_value_name}' is not a callable"
f"set_value_name '{entity_description.set_value_name}' is not a coroutine"
)
self._set_value_func: Callable[..., bool] = func
self._set_value_func: Callable[..., Awaitable[bool]] = func
self._set_value_args = entity_description.set_value_args
self._attr_native_unit_of_measurement = get_ha_unit(
self.entity_data.get(ATTR.UNIT)
@@ -145,9 +146,12 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity):
data_key = data_path[-1]
args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True)
# Current API requires int values for the currently supported numbers.
value = int(value)
args[self._data_key] = value
if self._set_value_func(*args.values()):
if await self._set_value_func(*args.values()):
_LOGGER.debug("Set '%s' to %s", self._data_key, value)
await self._async_refresh()
else:

View File

@@ -16,7 +16,6 @@ from homeassistant.components.recorder import (
get_instance,
history,
statistics,
util as recorder_util,
)
from homeassistant.components.recorder.models import (
StatisticData,
@@ -383,27 +382,7 @@ def _timestamp_to_isoformat_or_none(timestamp: float | None) -> str | None:
return dt_util.utc_from_timestamp(timestamp).isoformat()
def compile_statistics(
hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime
) -> statistics.PlatformCompiledStatistics:
"""Compile statistics for all entities during start-end.
Note: This will query the database and must not be run in the event loop
"""
# There is already an active session when this code is called since
# it is called from the recorder statistics. We need to make sure
# this session never gets committed since it would be out of sync
# with the recorder statistics session so we mark it as read only.
#
# If we ever need to write to the database from this function we
# will need to refactor the recorder statistics to use a single
# session.
with recorder_util.session_scope(hass=hass, read_only=True) as session:
compiled = _compile_statistics(hass, session, start, end)
return compiled
def _compile_statistics( # noqa: C901
def compile_statistics( # noqa: C901
hass: HomeAssistant,
session: Session,
start: datetime.datetime,
@@ -480,8 +459,8 @@ def _compile_statistics( # noqa: C901
if "sum" in wanted_statistics[entity_id]:
to_query.add(entity_id)
last_stats = statistics.get_latest_short_term_statistics(
hass, to_query, {"last_reset", "state", "sum"}, metadata=old_metadatas
last_stats = statistics.get_latest_short_term_statistics_with_session(
hass, session, to_query, {"last_reset", "state", "sum"}, metadata=old_metadatas
)
for ( # pylint: disable=too-many-nested-blocks
entity_id,

View File

@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
"iot_class": "local_polling",
"requirements": ["Pillow==10.0.0"]
"requirements": ["Pillow==10.0.1"]
}

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/sighthound",
"iot_class": "cloud_polling",
"loggers": ["simplehound"],
"requirements": ["Pillow==10.0.0", "simplehound==0.3"]
"requirements": ["Pillow==10.0.1", "simplehound==0.3"]
}

View File

@@ -166,7 +166,7 @@ class SlackNotificationService(BaseNotificationService):
filename=filename,
initial_comment=message,
title=title or filename,
thread_ts=thread_ts,
thread_ts=thread_ts or "",
)
except (SlackApiError, ClientError) as err:
_LOGGER.error("Error while uploading file-based message: %r", err)

View File

@@ -139,6 +139,13 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
),
"pv_isolation_resistance": SensorEntityDescription(
key="pv_isolation_resistance",
name="PV Isolation Resistance",
native_unit_of_measurement="kOhms",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
"insulation_residual_current": SensorEntityDescription(
key="insulation_residual_current",
name="Insulation Residual Current",
@@ -147,6 +154,13 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
),
"pv_power": SensorEntityDescription(
key="pv_power",
name="PV Power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
"grid_power": SensorEntityDescription(
key="grid_power",
name="Grid Power",
@@ -260,8 +274,6 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = {
"grid_power_factor_excitation": SensorEntityDescription(
key="grid_power_factor_excitation",
name="Grid Power Factor Excitation",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER_FACTOR,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -479,6 +491,30 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
),
"sps_voltage": SensorEntityDescription(
key="sps_voltage",
name="Secure Power Supply Voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
),
"sps_current": SensorEntityDescription(
key="sps_current",
name="Secure Power Supply Current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
entity_registry_enabled_default=False,
),
"sps_power": SensorEntityDescription(
key="sps_power",
name="Secure Power Supply Power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
),
"optimizer_power": SensorEntityDescription(
key="optimizer_power",
name="Optimizer Power",

View File

@@ -10,6 +10,6 @@
"iot_class": "local_push",
"loggers": ["systembridgeconnector"],
"quality_scale": "silver",
"requirements": ["systembridgeconnector==3.8.2"],
"requirements": ["systembridgeconnector==3.8.4"],
"zeroconf": ["_system-bridge._tcp.local."]
}

View File

@@ -10,6 +10,6 @@
"tf-models-official==2.5.0",
"pycocotools==2.0.6",
"numpy==1.26.0",
"Pillow==10.0.0"
"Pillow==10.0.1"
]
}

View File

@@ -35,6 +35,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Validate input from user input."""
errors: dict[str, str] = {}
camera_info: CameraInfo | None = None
camera_location: str | None = None
web_session = async_get_clientsession(self.hass)
camera_api = TrafikverketCamera(web_session, sensor_api)
@@ -49,7 +50,12 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
except UnknownError:
errors["base"] = "cannot_connect"
camera_location = camera_info.location if camera_info else None
if camera_info:
if _location := camera_info.location:
camera_location = _location
else:
camera_location = camera_info.camera_name
return (errors, camera_location)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/w800rf32",
"iot_class": "local_push",
"loggers": ["W800rf32"],
"requirements": ["pyW800rf32==0.1"]
"requirements": ["pyW800rf32==0.4"]
}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from abc import abstractmethod
import asyncio
from collections.abc import AsyncIterable
import logging
from typing import final
@@ -34,6 +35,8 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
TIMEOUT_FETCH_WAKE_WORDS = 10
@callback
def async_default_entity(hass: HomeAssistant) -> str | None:
@@ -86,9 +89,8 @@ class WakeWordDetectionEntity(RestoreEntity):
"""Return the state of the entity."""
return self.__last_detected
@property
@abstractmethod
def supported_wake_words(self) -> list[WakeWord]:
async def get_supported_wake_words(self) -> list[WakeWord]:
"""Return a list of supported wake words."""
@abstractmethod
@@ -133,8 +135,9 @@ class WakeWordDetectionEntity(RestoreEntity):
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
}
)
@websocket_api.async_response
@callback
def websocket_entity_info(
async def websocket_entity_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Get info about wake word entity."""
@@ -147,7 +150,16 @@ def websocket_entity_info(
)
return
try:
async with asyncio.timeout(TIMEOUT_FETCH_WAKE_WORDS):
wake_words = await entity.get_supported_wake_words()
except asyncio.TimeoutError:
connection.send_error(
msg["id"], websocket_api.const.ERR_TIMEOUT, "Timeout fetching wake words"
)
return
connection.send_result(
msg["id"],
{"wake_words": entity.supported_wake_words},
{"wake_words": wake_words},
)

View File

@@ -3,7 +3,7 @@ from enum import StrEnum
DOMAIN = "wallbox"
BIDIRECTIONAL_MODEL_PREFIXES = ["QSX"]
BIDIRECTIONAL_MODEL_PREFIXES = ["QS"]
CODE_KEY = "code"
CONF_STATION = "station"

View File

@@ -79,7 +79,7 @@ class WallboxNumber(WallboxEntity, NumberEntity):
self._coordinator = coordinator
self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}"
self._is_bidirectional = (
coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:3]
coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2]
in BIDIRECTIONAL_MODEL_PREFIXES
)

View File

@@ -1,13 +1,19 @@
"""The waze_travel_time component."""
import asyncio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN, SEMAPHORE
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Load the saved entities."""
if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}):
hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
DOMAIN = "waze_travel_time"
SEMAPHORE = "semaphore"
CONF_DESTINATION = "destination"
CONF_ORIGIN = "origin"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/waze_travel_time",
"iot_class": "cloud_polling",
"loggers": ["pywaze", "homeassistant.helpers.location"],
"requirements": ["pywaze==0.5.0"]
"requirements": ["pywaze==0.5.1"]
}

View File

@@ -43,6 +43,7 @@ from .const import (
DEFAULT_NAME,
DOMAIN,
IMPERIAL_UNITS,
SEMAPHORE,
)
_LOGGER = logging.getLogger(__name__)
@@ -51,7 +52,7 @@ SCAN_INTERVAL = timedelta(minutes=5)
PARALLEL_UPDATES = 1
MS_BETWEEN_API_CALLS = 0.5
SECONDS_BETWEEN_API_CALLS = 0.5
async def async_setup_entry(
@@ -148,8 +149,12 @@ class WazeTravelTime(SensorEntity):
_LOGGER.debug("Fetching Route for %s", self._attr_name)
self._waze_data.origin = find_coordinates(self.hass, self._origin)
self._waze_data.destination = find_coordinates(self.hass, self._destination)
await self._waze_data.async_update()
await asyncio.sleep(MS_BETWEEN_API_CALLS)
await self.hass.data[DOMAIN][SEMAPHORE].acquire()
try:
await self._waze_data.async_update()
await asyncio.sleep(SECONDS_BETWEEN_API_CALLS)
finally:
self.hass.data[DOMAIN][SEMAPHORE].release()
class WazeTravelTimeData:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyweatherflowudp"],
"requirements": ["pyweatherflowudp==1.4.3"]
"requirements": ["pyweatherflowudp==1.4.5"]
}

View File

@@ -245,7 +245,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = (
translation_key="wind_gust",
icon="mdi:weather-windy",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
raw_data_conv_fn=lambda raw_data: raw_data.magnitude,
@@ -255,7 +255,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = (
translation_key="wind_lull",
icon="mdi:weather-windy",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
raw_data_conv_fn=lambda raw_data: raw_data.magnitude,
@@ -265,17 +265,17 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.WIND_SPEED,
icon="mdi:weather-windy",
event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION],
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
raw_data_conv_fn=lambda raw_data: raw_data.magnitude,
),
WeatherFlowSensorEntityDescription(
key="wind_speed_average",
key="wind_average",
translation_key="wind_speed_average",
icon="mdi:weather-windy",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
raw_data_conv_fn=lambda raw_data: raw_data.magnitude,

View File

@@ -66,6 +66,7 @@ class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
try:
user_input[CONF_KEY_PEM] = self._fix_key_input(user_input[CONF_KEY_PEM])
await self._test_config(user_input)
except WeatherKitUnsupportedLocationError as exception:
LOGGER.error(exception)
@@ -104,6 +105,25 @@ class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors,
)
def _fix_key_input(self, key_input: str) -> str:
"""Fix common user errors with the key input."""
# OSes may sometimes turn two hyphens (--) into an em dash (—)
key_input = key_input.replace("", "--")
# Trim whitespace and line breaks
key_input = key_input.strip()
# Make sure header and footer are present
header = "-----BEGIN PRIVATE KEY-----"
if not key_input.startswith(header):
key_input = f"{header}\n{key_input}"
footer = "-----END PRIVATE KEY-----"
if not key_input.endswith(footer):
key_input += f"\n{footer}"
return key_input
async def _test_config(self, user_input: dict[str, Any]) -> None:
"""Validate credentials."""
client = WeatherKitApiClient(

View File

@@ -4,7 +4,10 @@ For more details about this platform, please refer to the documentation at
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import contextlib
from datetime import timedelta
from typing import Any
from aiohttp.hdrs import METH_HEAD, METH_POST
@@ -37,18 +40,10 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
from .api import ConfigEntryWithingsApi
from .const import (
CONF_CLOUDHOOK_URL,
CONF_PROFILES,
CONF_USE_WEBHOOK,
DEFAULT_TITLE,
DOMAIN,
LOGGER,
)
from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER
from .coordinator import WithingsDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -78,6 +73,9 @@ CONFIG_SCHEMA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
SUBSCRIBE_DELAY = timedelta(seconds=5)
UNSUBSCRIBE_DELAY = timedelta(seconds=1)
CONF_CLOUDHOOK_URL = "cloudhook_url"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -141,13 +139,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) -> None:
LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID])
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await hass.data[DOMAIN][entry.entry_id].async_unsubscribe_webhooks()
await async_unsubscribe_webhooks(client)
coordinator.webhook_subscription_listener(False)
async def register_webhook(
_: Any,
) -> None:
if cloud.async_active_subscription(hass):
webhook_url = await async_cloudhook_generate_url(hass, entry)
webhook_url = await _async_cloudhook_generate_url(hass, entry)
else:
webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID])
@@ -160,7 +159,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
webhook_name = "Withings"
if entry.title != DEFAULT_TITLE:
webhook_name = " ".join([DEFAULT_TITLE, entry.title])
webhook_name = f"{DEFAULT_TITLE} {entry.title}"
webhook_register(
hass,
@@ -170,7 +169,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
get_webhook_handler(coordinator),
)
await hass.data[DOMAIN][entry.entry_id].async_subscribe_webhooks(webhook_url)
await async_subscribe_webhooks(client, webhook_url)
coordinator.webhook_subscription_listener(True)
LOGGER.debug("Register Withings webhook: %s", webhook_url)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
@@ -182,17 +182,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED:
await unregister_webhook(None)
async_call_later(hass, 30, register_webhook)
entry.async_on_unload(async_call_later(hass, 30, register_webhook))
if cloud.async_active_subscription(hass):
if cloud.async_is_connected(hass):
await register_webhook(None)
cloud.async_listen_connection_change(hass, manage_cloudhook)
entry.async_on_unload(async_call_later(hass, 1, register_webhook))
entry.async_on_unload(
cloud.async_listen_connection_change(hass, manage_cloudhook)
)
else:
async_at_started(hass, register_webhook)
entry.async_on_unload(async_call_later(hass, 1, register_webhook))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
@@ -206,17 +207,62 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_subscribe_webhooks(
client: ConfigEntryWithingsApi, webhook_url: str
) -> None:
"""Subscribe to Withings webhooks."""
await async_unsubscribe_webhooks(client)
notification_to_subscribe = {
NotifyAppli.WEIGHT,
NotifyAppli.CIRCULATORY,
NotifyAppli.ACTIVITY,
NotifyAppli.SLEEP,
NotifyAppli.BED_IN,
NotifyAppli.BED_OUT,
}
for notification in notification_to_subscribe:
LOGGER.debug(
"Subscribing %s for %s in %s seconds",
webhook_url,
notification,
SUBSCRIBE_DELAY.total_seconds(),
)
# Withings will HTTP HEAD the callback_url and needs some downtime
# between each call or there is a higher chance of failure.
await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
await client.async_notify_subscribe(webhook_url, notification)
async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str:
async def async_unsubscribe_webhooks(client: ConfigEntryWithingsApi) -> None:
"""Unsubscribe to all Withings webhooks."""
current_webhooks = await client.async_notify_list()
for webhook_configuration in current_webhooks.profiles:
LOGGER.debug(
"Unsubscribing %s for %s in %s seconds",
webhook_configuration.callbackurl,
webhook_configuration.appli,
UNSUBSCRIBE_DELAY.total_seconds(),
)
# Quick calls to Withings can result in the service returning errors.
# Give them some time to cool down.
await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
await client.async_notify_revoke(
webhook_configuration.callbackurl, webhook_configuration.appli
)
async def _async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str:
"""Generate the full URL for a webhook_id."""
if CONF_CLOUDHOOK_URL not in entry.data:
webhook_url = await cloud.async_create_cloudhook(
hass, entry.data[CONF_WEBHOOK_ID]
)
webhook_id = entry.data[CONF_WEBHOOK_ID]
# Some users already have their webhook as cloudhook.
# We remove them to be sure we can create a new one.
with contextlib.suppress(ValueError):
await cloud.async_delete_cloudhook(hass, webhook_id)
webhook_url = await cloud.async_create_cloudhook(hass, webhook_id)
data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
hass.config_entries.async_update_entry(entry, data=data)
return webhook_url

View File

@@ -76,6 +76,7 @@ class WithingsFlowHandler(
self.hass.config_entries.async_update_entry(
self.reauth_entry, data={**self.reauth_entry.data, **data}
)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_abort(reason="wrong_account")

View File

@@ -5,7 +5,6 @@ import logging
DEFAULT_TITLE = "Withings"
CONF_PROFILES = "profiles"
CONF_USE_WEBHOOK = "use_webhook"
CONF_CLOUDHOOK_URL = "cloudhook_url"
DATA_MANAGER = "data_manager"

View File

@@ -1,5 +1,4 @@
"""Withings coordinator."""
import asyncio
from collections.abc import Callable
from datetime import timedelta
from typing import Any
@@ -24,9 +23,6 @@ from homeassistant.util import dt as dt_util
from .api import ConfigEntryWithingsApi
from .const import LOGGER, Measurement
SUBSCRIBE_DELAY = timedelta(seconds=5)
UNSUBSCRIBE_DELAY = timedelta(seconds=1)
WITHINGS_MEASURE_TYPE_MAP: dict[
NotifyAppli | GetSleepSummaryField | MeasureType, Measurement
] = {
@@ -84,55 +80,12 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]
super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL)
self._client = client
async def async_subscribe_webhooks(self, webhook_url: str) -> None:
"""Subscribe to webhooks."""
await self.async_unsubscribe_webhooks()
current_webhooks = await self._client.async_notify_list()
subscribed_notifications = frozenset(
profile.appli
for profile in current_webhooks.profiles
if profile.callbackurl == webhook_url
)
notification_to_subscribe = (
set(NotifyAppli)
- subscribed_notifications
- {NotifyAppli.USER, NotifyAppli.UNKNOWN}
)
for notification in notification_to_subscribe:
LOGGER.debug(
"Subscribing %s for %s in %s seconds",
webhook_url,
notification,
SUBSCRIBE_DELAY.total_seconds(),
)
# Withings will HTTP HEAD the callback_url and needs some downtime
# between each call or there is a higher chance of failure.
await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
await self._client.async_notify_subscribe(webhook_url, notification)
self.update_interval = None
async def async_unsubscribe_webhooks(self) -> None:
"""Unsubscribe to webhooks."""
current_webhooks = await self._client.async_notify_list()
for webhook_configuration in current_webhooks.profiles:
LOGGER.debug(
"Unsubscribing %s for %s in %s seconds",
webhook_configuration.callbackurl,
webhook_configuration.appli,
UNSUBSCRIBE_DELAY.total_seconds(),
)
# Quick calls to Withings can result in the service returning errors.
# Give them some time to cool down.
await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
await self._client.async_notify_revoke(
webhook_configuration.callbackurl, webhook_configuration.appli
)
self.update_interval = UPDATE_INTERVAL
def webhook_subscription_listener(self, connected: bool) -> None:
"""Call when webhook status changed."""
if connected:
self.update_interval = None
else:
self.update_interval = UPDATE_INTERVAL
async def _async_update_data(self) -> dict[Measurement, Any]:
try:

View File

@@ -240,8 +240,7 @@ SENSORS = [
key=Measurement.SLEEP_HEART_RATE_MAX.value,
measurement=Measurement.SLEEP_HEART_RATE_MAX,
measure_type=GetSleepSummaryField.HR_MAX,
translation_key="fat_mass",
name="Maximum heart rate",
translation_key="maximum_heart_rate",
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,
@@ -251,7 +250,7 @@ SENSORS = [
key=Measurement.SLEEP_HEART_RATE_MIN.value,
measurement=Measurement.SLEEP_HEART_RATE_MIN,
measure_type=GetSleepSummaryField.HR_MIN,
translation_key="maximum_heart_rate",
translation_key="minimum_heart_rate",
native_unit_of_measurement=UOM_BEATS_PER_MINUTE,
icon="mdi:heart-pulse",
state_class=SensorStateClass.MEASUREMENT,

View File

@@ -16,7 +16,8 @@
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"already_configured": "Configuration updated for profile.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]"
},
"create_entry": {
"default": "Successfully authenticated with Withings."
@@ -89,6 +90,9 @@
"maximum_heart_rate": {
"name": "Maximum heart rate"
},
"minimum_heart_rate": {
"name": "Minimum heart rate"
},
"light_sleep": {
"name": "Light sleep"
},

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