diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index aa4bfc60c11..cdffcbe4d5b 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.7.0 + uses: sigstore/cosign-installer@v3.8.0 with: cosign-release: "v2.2.3" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 863c861db75..2a9f1571830 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -975,6 +975,7 @@ jobs: ${cov_params[@]} \ -o console_output_style=count \ -p no:sugar \ + --exclude-warning-annotations \ $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt) \ 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output @@ -1098,6 +1099,7 @@ jobs: -o console_output_style=count \ --durations=10 \ -p no:sugar \ + --exclude-warning-annotations \ --dburl=mysql://root:password@127.0.0.1/homeassistant-test \ tests/components/history \ tests/components/logbook \ @@ -1228,6 +1230,7 @@ jobs: --durations=0 \ --durations-min=10 \ -p no:sugar \ + --exclude-warning-annotations \ --dburl=postgresql://postgres:password@127.0.0.1/homeassistant-test \ tests/components/history \ tests/components/logbook \ @@ -1374,6 +1377,7 @@ jobs: --durations=0 \ --durations-min=1 \ -p no:sugar \ + --exclude-warning-annotations \ tests/components/${{ matrix.group }} \ 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output diff --git a/.strict-typing b/.strict-typing index 4cebcb6f445..1e3187980cc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -119,6 +119,7 @@ homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* homeassistant.components.bond.* homeassistant.components.braviatv.* +homeassistant.components.bring.* homeassistant.components.brother.* homeassistant.components.browser.* homeassistant.components.bryant_evolution.* diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py index 3b27d6cda5e..8f7fd86847d 100644 --- a/homeassistant/components/airgradient/__init__.py +++ b/homeassistant/components/airgradient/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from airgradient import AirGradientClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import AirGradientCoordinator +from .coordinator import AirGradientConfigEntry, AirGradientCoordinator PLATFORMS: list[Platform] = [ Platform.BUTTON, @@ -21,9 +20,6 @@ PLATFORMS: list[Platform] = [ ] -type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool: """Set up Airgradient from a config entry.""" @@ -31,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) entry.data[CONF_HOST], session=async_get_clientsession(hass) ) - coordinator = AirGradientCoordinator(hass, client) + coordinator = AirGradientCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index d2fc2a9de1b..7484c7e85a9 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -4,18 +4,17 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING from airgradient import AirGradientClient, AirGradientError, Config, Measures +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -if TYPE_CHECKING: - from . import AirGradientConfigEntry +type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator] @dataclass @@ -32,11 +31,17 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): config_entry: AirGradientConfigEntry _current_version: str - def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: AirGradientConfigEntry, + client: AirGradientClient, + ) -> None: """Initialize coordinator.""" super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=f"AirGradient {client.host}", update_interval=timedelta(minutes=1), ) diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 1ae7da14875..d4a6e9c295f 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.4.3"] + "requirements": ["aioairq==0.4.4"] } diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 04bef105546..8bd393e2d11 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1531,7 +1531,7 @@ async def async_api_adjust_range( data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_delta = directive.payload["rangeValueDelta"] range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) - response_value: int | None = 0 + response_value: float | None = 0 # Cover Position if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index afaba5175da..d56d4e64b0f 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -387,4 +387,4 @@ def _validate_state_det_rules(state_det_rules: Any) -> list[Any] | None: except ValueError as exc: _LOGGER.warning("Invalid state detection rules: %s", exc) return None - return json_rules # type: ignore[no-any-return] + return json_rules diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index bfe3a61282e..e9905e4cce5 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -39,7 +39,7 @@ "idle": "[%key:common::state::idle%]", "cook": "Cooking", "low_water": "Low water", - "ota": "Ota", + "ota": "OTA update", "provisioning": "Provisioning", "high_temp": "High temperature", "device_failure": "Device failure" diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py index 75396cf138f..7612753e8c4 100644 --- a/homeassistant/components/assist_satellite/intent.py +++ b/homeassistant/components/assist_satellite/intent.py @@ -1,5 +1,7 @@ """Assist Satellite intents.""" +from typing import Final + import voluptuous as vol from homeassistant.core import HomeAssistant @@ -7,6 +9,8 @@ from homeassistant.helpers import entity_registry as er, intent from .const import DOMAIN, AssistSatelliteEntityFeature +EXCLUDED_DOMAINS: Final[set[str]] = {"voip"} + async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the intents.""" @@ -30,19 +34,36 @@ class BroadcastIntentHandler(intent.IntentHandler): ent_reg = er.async_get(hass) # Find all assist satellite entities that are not the one invoking the intent - entities = { - entity: entry - for entity in hass.states.async_entity_ids(DOMAIN) - if (entry := ent_reg.async_get(entity)) - and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE - } + entities: dict[str, er.RegistryEntry] = {} + for entity in hass.states.async_entity_ids(DOMAIN): + entry = ent_reg.async_get(entity) + if ( + (entry is None) + or ( + # Supports announce + not ( + entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE + ) + ) + # Not the invoking device + or (intent_obj.device_id and (entry.device_id == intent_obj.device_id)) + ): + # Skip satellite + continue - if intent_obj.device_id: - entities = { - entity: entry - for entity, entry in entities.items() - if entry.device_id != intent_obj.device_id - } + # Check domain of config entry against excluded domains + if ( + entry.config_entry_id + and ( + config_entry := hass.config_entries.async_get_entry( + entry.config_entry_id + ) + ) + and (config_entry.domain in EXCLUDED_DOMAINS) + ): + continue + + entities[entity] = entry await hass.services.async_call( DOMAIN, @@ -54,7 +75,6 @@ class BroadcastIntentHandler(intent.IntentHandler): ) response = intent_obj.create_response() - response.async_set_speech("Done") response.response_type = intent.IntentResponseType.ACTION_DONE response.async_set_results( success_results=[ diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fa9ca956c22..25393a872cc 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, replace from enum import StrEnum import hashlib import io +from itertools import chain import json from pathlib import Path, PurePath import shutil @@ -827,7 +828,7 @@ class BackupManager: password=None, ) await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add(written_backup.backup, agent_errors, []) return written_backup.backup.backup_id async def async_create_backup( @@ -951,12 +952,23 @@ class BackupManager: with_automatic_settings: bool, ) -> NewBackup: """Initiate generating a backup.""" - if not agent_ids: - raise BackupManagerError("At least one agent must be selected") - if invalid_agents := [ + unavailable_agents = [ agent_id for agent_id in agent_ids if agent_id not in self.backup_agents - ]: - raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") + ] + if not ( + available_agents := [ + agent_id for agent_id in agent_ids if agent_id in self.backup_agents + ] + ): + raise BackupManagerError( + f"At least one available backup agent must be selected, got {agent_ids}" + ) + if unavailable_agents: + LOGGER.warning( + "Backup agents %s are not available, will backupp to %s", + unavailable_agents, + available_agents, + ) if include_all_addons and include_addons: raise BackupManagerError( "Cannot include all addons and specify specific addons" @@ -973,7 +985,7 @@ class BackupManager: new_backup, self._backup_task, ) = await self._reader_writer.async_create_backup( - agent_ids=agent_ids, + agent_ids=available_agents, backup_name=backup_name, extra_metadata=extra_metadata | { @@ -992,7 +1004,9 @@ class BackupManager: raise BackupManagerError(str(err)) from err backup_finish_task = self._backup_finish_task = self.hass.async_create_task( - self._async_finish_backup(agent_ids, with_automatic_settings, password), + self._async_finish_backup( + available_agents, unavailable_agents, with_automatic_settings, password + ), name="backup_manager_finish_backup", ) if not raise_task_error: @@ -1009,7 +1023,11 @@ class BackupManager: return new_backup async def _async_finish_backup( - self, agent_ids: list[str], with_automatic_settings: bool, password: str | None + self, + available_agents: list[str], + unavailable_agents: list[str], + with_automatic_settings: bool, + password: str | None, ) -> None: """Finish a backup.""" if TYPE_CHECKING: @@ -1028,7 +1046,7 @@ class BackupManager: LOGGER.debug( "Generated new backup with backup_id %s, uploading to agents %s", written_backup.backup.backup_id, - agent_ids, + available_agents, ) self.async_on_backup_event( CreateBackupEvent( @@ -1041,13 +1059,15 @@ class BackupManager: try: agent_errors = await self._async_upload_backup( backup=written_backup.backup, - agent_ids=agent_ids, + agent_ids=available_agents, open_stream=written_backup.open_stream, password=password, ) finally: await written_backup.release_stream() - self.known_backups.add(written_backup.backup, agent_errors) + self.known_backups.add( + written_backup.backup, agent_errors, unavailable_agents + ) if not agent_errors: if with_automatic_settings: # create backup was successful, update last_completed_automatic_backup @@ -1056,7 +1076,7 @@ class BackupManager: backup_success = True if with_automatic_settings: - self._update_issue_after_agent_upload(agent_errors) + self._update_issue_after_agent_upload(agent_errors, unavailable_agents) # delete old backups more numerous than copies # try this regardless of agent errors above await delete_backups_exceeding_configured_count(self) @@ -1216,10 +1236,10 @@ class BackupManager: ) def _update_issue_after_agent_upload( - self, agent_errors: dict[str, Exception] + self, agent_errors: dict[str, Exception], unavailable_agents: list[str] ) -> None: """Update issue registry after a backup is uploaded to agents.""" - if not agent_errors: + if not agent_errors and not unavailable_agents: ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") return ir.async_create_issue( @@ -1233,7 +1253,13 @@ class BackupManager: translation_key="automatic_backup_failed_upload_agents", translation_placeholders={ "failed_agents": ", ".join( - self.backup_agents[agent_id].name for agent_id in agent_errors + chain( + ( + self.backup_agents[agent_id].name + for agent_id in agent_errors + ), + unavailable_agents, + ) ) }, ) @@ -1302,11 +1328,12 @@ class KnownBackups: self, backup: AgentBackup, agent_errors: dict[str, Exception], + unavailable_agents: list[str], ) -> None: """Add a backup.""" self._backups[backup.backup_id] = KnownBackup( backup_id=backup.backup_id, - failed_agent_ids=list(agent_errors), + failed_agent_ids=list(chain(agent_errors, unavailable_agents)), ) self._manager.store.save() @@ -1412,7 +1439,11 @@ class CoreBackupReaderWriter(BackupReaderWriter): manager = self._hass.data[DATA_MANAGER] agent_config = manager.config.data.agents.get(self._local_agent_id) - if agent_config and not agent_config.protected: + if ( + self._local_agent_id in agent_ids + and agent_config + and not agent_config.protected + ): password = None backup = AgentBackup( diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index b920c66a9b8..9d8f6e815dc 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -122,7 +122,7 @@ def read_backup(backup_path: Path) -> AgentBackup: def suggested_filename_from_name_date(name: str, date_str: str) -> str: """Suggest a filename for the backup.""" date = dt_util.parse_datetime(date_str, raise_on_error=True) - return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) + return "_".join(f"{name} {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) def suggested_filename(backup: AgentBackup) -> str: diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 6cf1957f799..37e83ce2c47 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -1,8 +1,6 @@ """The bluesound component.""" -from dataclasses import dataclass - -from pyblu import Player, SyncStatus +from pyblu import Player from pyblu.errors import PlayerUnreachableError from homeassistant.config_entries import ConfigEntry @@ -14,7 +12,11 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import BluesoundCoordinator +from .coordinator import ( + BluesoundConfigEntry, + BluesoundCoordinator, + BluesoundRuntimeData, +) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -23,18 +25,6 @@ PLATFORMS = [ ] -@dataclass -class BluesoundRuntimeData: - """Bluesound data class.""" - - player: Player - sync_status: SyncStatus - coordinator: BluesoundCoordinator - - -type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Bluesound.""" return True @@ -53,7 +43,7 @@ async def async_setup_entry( except PlayerUnreachableError as ex: raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex - coordinator = BluesoundCoordinator(hass, player, sync_status) + coordinator = BluesoundCoordinator(hass, config_entry, player, sync_status) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator) diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py index e62f3ef96cf..ceaf0b392eb 100644 --- a/homeassistant/components/bluesound/coordinator.py +++ b/homeassistant/components/bluesound/coordinator.py @@ -12,6 +12,7 @@ import logging from pyblu import Input, Player, Preset, Status, SyncStatus from pyblu.errors import PlayerUnreachableError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -21,6 +22,15 @@ NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3) PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15) +@dataclass +class BluesoundRuntimeData: + """Bluesound data class.""" + + player: Player + sync_status: SyncStatus + coordinator: BluesoundCoordinator + + @dataclass class BluesoundData: """Define a class to hold Bluesound data.""" @@ -31,6 +41,9 @@ class BluesoundData: inputs: list[Input] +type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] + + def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]: """Cancel a task.""" @@ -45,8 +58,14 @@ def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]] class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): """Define an object to hold Bluesound data.""" + config_entry: BluesoundConfigEntry + def __init__( - self, hass: HomeAssistant, player: Player, sync_status: SyncStatus + self, + hass: HomeAssistant, + config_entry: BluesoundConfigEntry, + player: Player, + sync_status: SyncStatus, ) -> None: """Initialize.""" self.player = player @@ -55,12 +74,11 @@ class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=sync_status.name, ) async def _async_setup(self) -> None: - assert self.config_entry is not None - preset = await self.player.presets() inputs = await self.player.inputs() status = await self.player.status() diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index a0405eb5ef5..5d2b8ab6285 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.4", - "dbus-fast==2.32.0", + "dbus-fast==2.33.0", "habluetooth==3.21.1" ] } diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 0ee8e3b3155..6dd2d36351c 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -6,19 +6,16 @@ import logging from bring_api import Bring -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import BringDataUpdateCoordinator +from .coordinator import BringConfigEntry, BringDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] +PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) -type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" @@ -26,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo session = async_get_clientsession(hass) bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) - coordinator = BringDataUpdateCoordinator(hass, bring) + coordinator = BringDataUpdateCoordinator(hass, entry, bring) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -36,6 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index bfb5a2cd50f..9e5f4da8356 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -68,7 +68,13 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "google_play": "https://play.google.com/store/apps/details?id=ch.publisheria.bring", + "app_store": "https://itunes.apple.com/app/apple-store/id580669177", + }, ) async def async_step_reauth( @@ -101,6 +107,29 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconf_entry = self._get_reconfigure_entry() + + if user_input: + if not (errors := await self.validate_input(user_input)): + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconf_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values={CONF_EMAIL: reconf_entry.data[CONF_EMAIL]}, + ), + errors=errors, + ) + async def validate_input(self, user_input: Mapping[str, Any]) -> dict[str, str]: """Auth Helper.""" diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 9473d0614e3..e1f9fa45ac8 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -8,11 +8,15 @@ import logging from bring_api import ( Bring, + BringActivityResponse, BringAuthException, + BringItemsResponse, + BringList, BringParseException, BringRequestException, + BringUserSettingsResponse, + BringUsersResponse, ) -from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse from mashumaro.mixins.orjson import DataClassORJSONMixin from homeassistant.config_entries import ConfigEntry @@ -26,6 +30,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator] + @dataclass(frozen=True) class BringData(DataClassORJSONMixin): @@ -33,20 +39,25 @@ class BringData(DataClassORJSONMixin): lst: BringList content: BringItemsResponse + activity: BringActivityResponse + users: BringUsersResponse class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): """A Bring Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: BringConfigEntry user_settings: BringUserSettingsResponse lists: list[BringList] - def __init__(self, hass: HomeAssistant, bring: Bring) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring + ) -> None: """Initialize the Bring data coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=90), ) @@ -59,23 +70,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): try: self.lists = (await self.bring.load_lists()).lists except BringRequestException as e: - raise UpdateFailed("Unable to connect and retrieve data from bring") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e except BringParseException as e: - raise UpdateFailed("Unable to parse response from bring") from e - except BringAuthException: - # try to recover by refreshing access token, otherwise - # initiate reauth flow - try: - await self.bring.retrieve_new_access_token() - except (BringRequestException, BringParseException) as exc: - raise UpdateFailed("Refreshing authentication token failed") from exc - except BringAuthException as exc: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_EMAIL: self.bring.mail}, - ) from exc - return self.data + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + except BringAuthException as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={CONF_EMAIL: self.bring.mail}, + ) from e if self.previous_lists - ( current_lists := {lst.listUuid for lst in self.lists} @@ -89,14 +98,20 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): continue try: items = await self.bring.get_list(lst.listUuid) + activity = await self.bring.get_activity(lst.listUuid) + users = await self.bring.get_list_users(lst.listUuid) except BringRequestException as e: raise UpdateFailed( - "Unable to connect and retrieve data from bring" + translation_domain=DOMAIN, + translation_key="setup_request_exception", ) from e except BringParseException as e: - raise UpdateFailed("Unable to parse response from bring") from e + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e else: - list_dict[lst.listUuid] = BringData(lst, items) + list_dict[lst.listUuid] = BringData(lst, items, activity, users) return list_dict diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index 1dec8f3a5ed..6c2f779ef05 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import BringConfigEntry +from .coordinator import BringConfigEntry async def async_get_config_entry_diagnostics( @@ -14,4 +14,8 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - return {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()} + return { + "data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()}, + "lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], + "user_settings": config_entry.runtime_data.user_settings.to_dict(), + } diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 3de0140d82c..ee90f22beef 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from bring_api.types import BringList +from bring_api import BringList from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py new file mode 100644 index 00000000000..699dba9015a --- /dev/null +++ b/homeassistant/components/bring/event.py @@ -0,0 +1,108 @@ +"""Event platform for Bring integration.""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime + +from bring_api import ActivityType, BringList + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BringConfigEntry +from .coordinator import BringDataUpdateCoordinator +from .entity import BringBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BringConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the event platform.""" + coordinator = config_entry.runtime_data + lists_added: set[str] = set() + + @callback + def add_entities() -> None: + """Add event entities.""" + nonlocal lists_added + + if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added: + async_add_entities( + BringEventEntity( + coordinator, + bring_list, + ) + for bring_list in coordinator.lists + if bring_list.listUuid in new_lists + ) + lists_added |= new_lists + + coordinator.async_add_listener(add_entities) + add_entities() + + +class BringEventEntity(BringBaseEntity, EventEntity): + """An event entity.""" + + _attr_translation_key = "activities" + + def __init__( + self, + coordinator: BringDataUpdateCoordinator, + bring_list: BringList, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, bring_list) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{self._list_uuid}_activities" + ) + self._attr_event_types = [event.name.lower() for event in ActivityType] + + def _async_handle_event(self) -> None: + """Handle the activity event.""" + bring_list = self.coordinator.data[self._list_uuid] + last_event_triggered = self.state + if bring_list.activity.timeline and ( + last_event_triggered is None + or datetime.fromisoformat(last_event_triggered) + < bring_list.activity.timestamp + ): + activity = bring_list.activity.timeline[0] + attributes = asdict(activity.content) + + attributes["last_activity_by"] = next( + x.name + for x in bring_list.users.users + if x.publicUuid == activity.content.publicUserUuid + ) + + self._trigger_event( + activity.type.name.lower(), + attributes, + ) + self.async_write_ha_state() + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + + return ( + f"https://api.getbring.com/rest/v2/bringusers/profilepictures/{public_uuid}" + if (public_uuid := self.state_attributes.get("publicUserUuid")) + else super().entity_picture + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks with your device API/library.""" + await super().async_added_to_hass() + self._async_handle_event() + + def _handle_coordinator_update(self) -> None: + self._async_handle_event() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index c670ef87700..ea4f4e877bc 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -1,5 +1,10 @@ { "entity": { + "event": { + "activity": { + "default": "mdi:bell" + } + }, "sensor": { "urgent": { "default": "mdi:run-fast" diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index ecd3e911078..16767b7b0d6 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], - "requirements": ["bring-api==1.0.0"] + "requirements": ["bring-api==1.0.1"] } diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 0b4191d5c61..58e67ab0e11 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -7,7 +7,7 @@ rules: brands: done common-modules: done config-flow-test-coverage: done - config-flow: todo + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: todo @@ -58,9 +58,9 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | @@ -69,4 +69,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 651307a2eee..bfe93619dbb 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -6,9 +6,8 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from bring_api import BringUserSettingsResponse +from bring_api import BringList, BringUserSettingsResponse from bring_api.const import BRING_SUPPORTED_LOCALES -from bring_api.types import BringList from homeassistant.components.sensor import ( SensorDeviceClass, @@ -20,8 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import BringConfigEntry -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity from .util import list_language, sum_attributes diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index ea9af03484e..f8c261db3fd 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -5,9 +5,15 @@ "config": { "step": { "user": { + "title": "Bring! Grocery shopping list", + "description": "Connect your Bring! account to sync your shopping lists with Home Assistant.\n\nDon't have a Bring! account? Download the app on [Google Play for Android]({google_play}) or the [App Store for iOS]({app_store}) to sign up.", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address associated with your Bring! account.", + "password": "The password to login to your Bring! account." } }, "reauth_confirm": { @@ -16,21 +22,53 @@ "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::bring::config::step::user::data_description::email%]", + "password": "[%key:component::bring::config::step::user::data_description::email%]" + } + }, + "reconfigure": { + "title": "Bring! configuration", + "description": "Update your credentials if you have changed your Bring! account email or password.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "[%key:component::bring::config::step::user::data_description::email%]", + "password": "[%key:component::bring::config::step::user::data_description::email%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." + "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { + "event": { + "activities": { + "name": "Activities", + "state_attributes": { + "event_type": { + "state": { + "list_items_added": "Items added", + "list_items_changed": "Items changed", + "list_items_removed": "Items removed" + } + } + } + } + }, "sensor": { "urgent": { "name": "Urgent", diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index ad4de4196c1..4de306273f3 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -9,10 +9,10 @@ import uuid from bring_api import ( BringItem, BringItemOperation, + BringList, BringNotificationType, BringRequestException, ) -from bring_api.types import BringList import voluptuous as vol from homeassistant.components.todo import ( @@ -26,14 +26,13 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BringConfigEntry from .const import ( ATTR_ITEM_NAME, ATTR_NOTIFICATION_TYPE, DOMAIN, SERVICE_PUSH_NOTIFICATION, ) -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index c2bf1b2dce1..7a426112d04 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -174,7 +174,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE def __init__( self, - name: str, + name: str | None, entity_id: str, coordinator: CalDavUpdateCoordinator, unique_id: str | None = None, diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index d42e846259c..9531604ccc7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,16 +8,12 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any +from typing import Any, Literal -from aiohttp import ClientError, ClientTimeout +from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.cloud_api import ( - async_files_delete_file, - async_files_download_details, - async_files_list, - async_files_upload_details, -) +from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -28,7 +24,7 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP = "backup" +_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -109,63 +105,14 @@ class CloudBackupAgent(BackupAgent): raise BackupAgentError("Backup not found") try: - details = await async_files_download_details( - self._cloud, + content = await self._cloud.files.download( storage_type=_STORAGE_BACKUP, filename=self._get_backup_filename(), ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get download details") from err + except CloudError as err: + raise BackupAgentError(f"Failed to download backup: {err}") from err - try: - resp = await self._cloud.websession.get( - details["url"], - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - - resp.raise_for_status() - except ClientError as err: - raise BackupAgentError("Failed to download backup") from err - - return ChunkAsyncStreamIterator(resp.content) - - async def _async_do_upload_backup( - self, - *, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - filename: str, - base64md5hash: str, - metadata: dict[str, Any], - size: int, - ) -> None: - """Upload a backup.""" - try: - details = await async_files_upload_details( - self._cloud, - storage_type=_STORAGE_BACKUP, - filename=filename, - metadata=metadata, - size=size, - base64md5hash=base64md5hash, - ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get upload details") from err - - try: - upload_status = await self._cloud.websession.put( - details["url"], - data=await open_stream(), - headers=details["headers"] | {"content-length": str(size)}, - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - _LOGGER.log( - logging.DEBUG if upload_status.status < 400 else logging.WARNING, - "Backup upload status: %s", - upload_status.status, - ) - upload_status.raise_for_status() - except (TimeoutError, ClientError) as err: - raise BackupAgentError("Failed to upload backup") from err + return ChunkAsyncStreamIterator(content) async def async_upload_backup( self, @@ -190,7 +137,8 @@ class CloudBackupAgent(BackupAgent): tries = 1 while tries <= _RETRY_LIMIT: try: - await self._async_do_upload_backup( + await self._cloud.files.upload( + storage_type=_STORAGE_BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -198,9 +146,19 @@ class CloudBackupAgent(BackupAgent): size=size, ) break - except BackupAgentError as err: + except CloudApiNonRetryableError as err: + if err.code == "NC-SH-FH-03": + raise BackupAgentError( + translation_domain=DOMAIN, + translation_key="backup_size_too_large", + translation_placeholders={ + "size": str(round(size / (1024**3), 2)) + }, + ) from err + raise BackupAgentError(f"Failed to upload backup {err}") from err + except CloudError as err: if tries == _RETRY_LIMIT: - raise + raise BackupAgentError(f"Failed to upload backup {err}") from err tries += 1 retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) _LOGGER.info( diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 1da91f67813..6380ee9c312 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -17,6 +17,11 @@ "subscription_expiration": "Subscription expiration" } }, + "exceptions": { + "backup_size_too_large": { + "message": "The backup size of {size}GB is too large to be uploaded to Home Assistant Cloud." + } + }, "issues": { "deprecated_gender": { "title": "The {deprecated_option} text-to-speech option is deprecated", diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 0f47d4bc208..f20b23dad7a 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -132,6 +132,7 @@ WALLETS = { "GYD": "GYD", "HKD": "HKD", "HNL": "HNL", + "HNT": "HNT", "HRK": "HRK", "HTG": "HTG", "HUF": "HUF", @@ -410,6 +411,7 @@ RATES = { "GYEN": "GYEN", "HKD": "HKD", "HNL": "HNL", + "HNT": "HNT", "HRK": "HRK", "HTG": "HTG", "HUF": "HUF", diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index 96bf021e394..74510731b7a 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -2,19 +2,19 @@ "config": { "step": { "user": { - "title": "Coinbase API Key Details", + "title": "Coinbase API key details", "description": "Please enter the details of your API key as provided by Coinbase.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "api_token": "API Secret" + "api_token": "API secret" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API Key.", - "invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API Secret.", + "invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API key.", + "invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API secret.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -24,12 +24,12 @@ "options": { "step": { "init": { - "description": "Adjust Coinbase Options", + "description": "Adjust Coinbase options", "data": { "account_balance_currencies": "Wallet balances to report.", "exchange_rate_currencies": "Exchange rates to report.", "exchange_base": "Base currency for exchange rate sensors.", - "exchnage_rate_precision": "Number of decimal places for exchange rates." + "exchange_rate_precision": "Number of decimal places for exchange rates." } } }, diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 53e248d0a98..ad7a9d0ce9e 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -43,13 +43,6 @@ def async_get_chat_log( else: history = ChatLog(hass, session.conversation_id) - @callback - def do_cleanup() -> None: - """Handle cleanup.""" - all_history.pop(session.conversation_id) - - session.async_on_cleanup(do_cleanup) - if user_input is not None: history.async_add_user_content(UserContent(content=user_input.text)) @@ -63,6 +56,15 @@ def async_get_chat_log( ) return + if session.conversation_id not in all_history: + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_history.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + all_history[session.conversation_id] = history diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0485cb75fcb..2d4a8053d75 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"] } diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 7747ca4f95d..1d1ca6f84c7 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.5"], + "requirements": ["eheimdigital==1.0.6"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index de8d87553a3..825dbc54013 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -4,12 +4,16 @@ from __future__ import annotations import aiohttp from electrickiwi_api import ElectricKiwiApi -from electrickiwi_api.exceptions import ApiException +from electrickiwi_api.exceptions import ApiException, AuthException from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + entity_registry as er, +) from . import api from .coordinator import ( @@ -44,7 +48,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err ek_api = ElectricKiwiApi( - api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) ) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) @@ -53,6 +59,8 @@ async def async_setup_entry( await ek_api.set_active_session() await hop_coordinator.async_config_entry_first_refresh() await account_coordinator.async_config_entry_first_refresh() + except AuthException as err: + raise ConfigEntryAuthFailed from err except ApiException as err: raise ConfigEntryNotReady from err @@ -70,3 +78,53 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: ElectricKiwiConfigEntry +) -> bool: + """Migrate old entry.""" + if config_entry.version == 1 and config_entry.minor_version == 1: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + + ek_api = ElectricKiwiApi( + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + ) + try: + await ek_api.set_active_session() + connection_details = await ek_api.get_connection_details() + except AuthException: + config_entry.async_start_reauth(hass) + return False + except ApiException: + return False + unique_id = str(ek_api.customer_number) + identifier = ek_api.electricity.identifier + hass.config_entries.async_update_entry( + config_entry, unique_id=unique_id, minor_version=2 + ) + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + + for entity in entity_entries: + assert entity.config_entry_id + entity_registry.async_update_entity( + entity.entity_id, + new_unique_id=entity.unique_id.replace( + f"{unique_id}_{connection_details.id}", f"{unique_id}_{identifier}" + ), + ) + + return True diff --git a/homeassistant/components/electric_kiwi/api.py b/homeassistant/components/electric_kiwi/api.py index dead8a6a3c0..9f7ff333378 100644 --- a/homeassistant/components/electric_kiwi/api.py +++ b/homeassistant/components/electric_kiwi/api.py @@ -2,17 +2,16 @@ from __future__ import annotations -from typing import cast - from aiohttp import ClientSession from electrickiwi_api import AbstractAuth -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .const import API_BASE_URL -class AsyncConfigEntryAuth(AbstractAuth): +class ConfigEntryElectricKiwiAuth(AbstractAuth): """Provide Electric Kiwi authentication tied to an OAuth2 based config entry.""" def __init__( @@ -29,4 +28,21 @@ class AsyncConfigEntryAuth(AbstractAuth): """Return a valid access token.""" await self._oauth_session.async_ensure_token_valid() - return cast(str, self._oauth_session.token["access_token"]) + return str(self._oauth_session.token["access_token"]) + + +class ConfigFlowElectricKiwiAuth(AbstractAuth): + """Provide Electric Kiwi authentication tied to an OAuth2 based config flow.""" + + def __init__( + self, + hass: HomeAssistant, + token: str, + ) -> None: + """Initialize ConfigFlowFitbitApi.""" + super().__init__(aiohttp_client.async_get_clientsession(hass), API_BASE_URL) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Electric Kiwi API.""" + return self._token diff --git a/homeassistant/components/electric_kiwi/config_flow.py b/homeassistant/components/electric_kiwi/config_flow.py index b74ab4268e2..b83fd89c4c6 100644 --- a/homeassistant/components/electric_kiwi/config_flow.py +++ b/homeassistant/components/electric_kiwi/config_flow.py @@ -6,9 +6,14 @@ from collections.abc import Mapping import logging from typing import Any -from homeassistant.config_entries import ConfigFlowResult +from electrickiwi_api import ElectricKiwiApi +from electrickiwi_api.exceptions import ApiException + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_NAME from homeassistant.helpers import config_entry_oauth2_flow +from . import api from .const import DOMAIN, SCOPE_VALUES @@ -17,6 +22,8 @@ class ElectricKiwiOauth2FlowHandler( ): """Config flow to handle Electric Kiwi OAuth2 authentication.""" + VERSION = 1 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -40,12 +47,30 @@ class ElectricKiwiOauth2FlowHandler( ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) return await self.async_step_user() async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for Electric Kiwi.""" - existing_entry = await self.async_set_unique_id(DOMAIN) - if existing_entry: - return self.async_update_reload_and_abort(existing_entry, data=data) - return await super().async_oauth_create_entry(data) + ek_api = ElectricKiwiApi( + api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"]) + ) + + try: + session = await ek_api.get_active_session() + except ApiException: + return self.async_abort(reason="connection_error") + + unique_id = str(session.data.customer_number) + await self.async_set_unique_id(unique_id) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=unique_id, data=data) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 907b6247172..c51422a7c72 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -8,4 +8,4 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize" OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" -SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" +SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 2065da5d668..635b55b2bc0 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -10,7 +10,7 @@ import logging from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import AccountSummary, Hop, HopIntervals from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData: type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] -class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): +class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]): """ElectricKiwi Account Data object.""" def __init__( @@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): name="Electric Kiwi Account Data", update_interval=ACCOUNT_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api - async def _async_update_data(self) -> AccountBalance: + async def _async_update_data(self) -> AccountSummary: """Fetch data from Account balance API endpoint.""" try: async with asyncio.timeout(60): - return await self._ek_api.get_account_balance() + return await self.ek_api.get_account_summary() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): # Polling interval. Will only be polled if there are subscribers. update_interval=HOP_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api self.hop_intervals: HopIntervals | None = None def get_hop_options(self) -> dict[str, int]: @@ -100,7 +100,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): async def async_update_hop(self, hop_interval: int) -> Hop: """Update selected hop and data.""" try: - self.async_set_updated_data(await self._ek_api.post_hop(hop_interval)) + self.async_set_updated_data(await self.ek_api.post_hop(hop_interval)) except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): try: async with asyncio.timeout(60): if self.hop_intervals is None: - hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() + hop_intervals: HopIntervals = await self.ek_api.get_hop_intervals() hop_intervals.intervals = OrderedDict( filter( lambda pair: pair[1].active == 1, @@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): ) self.hop_intervals = hop_intervals - return await self._ek_api.get_hop() + return await self.ek_api.get_hop() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 8ddb4c1af7c..45bb09ca475 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.8.5"] + "requirements": ["electrickiwi-api==0.9.14"] } diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index fa111381612..30e02b5c5b9 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -53,8 +53,8 @@ class ElectricKiwiSelectHOPEntity( """Initialise the HOP selection entity.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description self.values_dict = coordinator.get_hop_options() diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index e070f9495c1..410d70808c3 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from electrickiwi_api.model import AccountBalance, Hop +from electrickiwi_api.model import AccountSummary, Hop from homeassistant.components.sensor import ( SensorDeviceClass, @@ -39,7 +39,15 @@ ATTR_HOP_PERCENTAGE = "hop_percentage" class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription): """Describes Electric Kiwi sensor entity.""" - value_func: Callable[[AccountBalance], float | datetime] + value_func: Callable[[AccountSummary], float | datetime] + + +def _get_hop_percentage(account_balance: AccountSummary) -> float: + """Return the hop percentage from account summary.""" + if power := account_balance.services.get("power"): + if connection := power.connections[0]: + return float(connection.hop_percentage) + return 0.0 ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( @@ -72,9 +80,7 @@ ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( translation_key="hop_power_savings", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - value_func=lambda account_balance: float( - account_balance.connections[0].hop_percentage - ), + value_func=_get_hop_percentage, ), ) @@ -165,8 +171,8 @@ class ElectricKiwiAccountEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description @@ -194,8 +200,8 @@ class ElectricKiwiHOPEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 410d32909ba..5e0a2ef168d 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -21,7 +21,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index aa303a08795..bf5385b6f2a 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -3,29 +3,16 @@ from __future__ import annotations from datetime import timedelta -import logging - -from pyfireservicerota import ( - ExpiredTokenError, - FireServiceRota, - FireServiceRotaIncidents, - InvalidAuthError, - InvalidTokenError, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN, WSS_BWRURL +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] @@ -40,17 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if client.token_refresh_failure: return False - async def async_update_data(): - return await client.async_update() - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="duty binary sensor", - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) + coordinator = FireServiceUpdateCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -68,171 +45,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload FireServiceRota config entry.""" await hass.async_add_executor_job( - hass.data[DOMAIN][entry.entry_id].websocket.stop_listener + hass.data[DOMAIN][entry.entry_id][DATA_CLIENT].websocket.stop_listener ) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class FireServiceRotaOauth: - """Handle authentication tokens.""" - - def __init__(self, hass, entry, fsr): - """Initialize the oauth object.""" - self._hass = hass - self._entry = entry - - self._url = entry.data[CONF_URL] - self._username = entry.data[CONF_USERNAME] - self._fsr = fsr - - async def async_refresh_tokens(self) -> bool: - """Refresh tokens and update config entry.""" - _LOGGER.debug("Refreshing authentication tokens after expiration") - - try: - token_info = await self._hass.async_add_executor_job( - self._fsr.refresh_tokens - ) - - except (InvalidAuthError, InvalidTokenError) as err: - raise ConfigEntryAuthFailed( - "Error refreshing tokens, triggered reauth workflow" - ) from err - - _LOGGER.debug("Saving new tokens in config entry") - self._hass.config_entries.async_update_entry( - self._entry, - data={ - "auth_implementation": DOMAIN, - CONF_URL: self._url, - CONF_USERNAME: self._username, - CONF_TOKEN: token_info, - }, - ) - - return True - - -class FireServiceRotaWebSocket: - """Define a FireServiceRota websocket manager object.""" - - def __init__(self, hass, entry): - """Initialize the websocket object.""" - self._hass = hass - self._entry = entry - - self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident) - self.incident_data = None - - def _construct_url(self) -> str: - """Return URL with latest access token.""" - return WSS_BWRURL.format( - self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"] - ) - - def _on_incident(self, data) -> None: - """Received new incident, update data.""" - _LOGGER.debug("Received new incident via websocket: %s", data) - self.incident_data = data - dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update") - - def start_listener(self) -> None: - """Start the websocket listener.""" - _LOGGER.debug("Starting incidents listener") - self._fsr_incidents.start(self._construct_url()) - - def stop_listener(self) -> None: - """Stop the websocket listener.""" - _LOGGER.debug("Stopping incidents listener") - self._fsr_incidents.stop() - - -class FireServiceRotaClient: - """Getting the latest data from fireservicerota.""" - - def __init__(self, hass, entry): - """Initialize the data object.""" - self._hass = hass - self._entry = entry - - self._url = entry.data[CONF_URL] - self._tokens = entry.data[CONF_TOKEN] - - self.entry_id = entry.entry_id - self.unique_id = entry.unique_id - - self.token_refresh_failure = False - self.incident_id = None - self.on_duty = False - - self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens) - - self.oauth = FireServiceRotaOauth( - self._hass, - self._entry, - self.fsr, - ) - - self.websocket = FireServiceRotaWebSocket(self._hass, self._entry) - - async def setup(self) -> None: - """Set up the data client.""" - await self._hass.async_add_executor_job(self.websocket.start_listener) - - async def update_call(self, func, *args): - """Perform update call and return data.""" - if self.token_refresh_failure: - return None - - try: - return await self._hass.async_add_executor_job(func, *args) - except (ExpiredTokenError, InvalidTokenError): - await self._hass.async_add_executor_job(self.websocket.stop_listener) - self.token_refresh_failure = True - - if await self.oauth.async_refresh_tokens(): - self.token_refresh_failure = False - await self._hass.async_add_executor_job(self.websocket.start_listener) - - return await self._hass.async_add_executor_job(func, *args) - - async def async_update(self) -> dict | None: - """Get the latest availability data.""" - data = await self.update_call( - self.fsr.get_availability, str(self._hass.config.time_zone) - ) - - if not data: - return None - - self.on_duty = bool(data.get("available")) - - _LOGGER.debug("Updated availability data: %s", data) - return data - - async def async_response_update(self) -> dict | None: - """Get the latest incident response data.""" - - if not self.incident_id: - return None - - _LOGGER.debug("Updating response data for incident id %s", self.incident_id) - - return await self.update_call(self.fsr.get_incident_response, self.incident_id) - - async def async_set_response(self, value) -> None: - """Set incident response status.""" - - if not self.incident_id: - return - - _LOGGER.debug( - "Setting incident response for incident id '%s' to state '%s'", - self.incident_id, - value, - ) - - await self.update_call(self.fsr.set_incident_response, self.incident_id, value) diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index a22991f2008..b6d3aa67a0a 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -8,13 +8,10 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FireServiceRotaClient from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator async def async_setup_entry( @@ -26,14 +23,16 @@ async def async_setup_entry( DATA_CLIENT ] - coordinator: DataUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ + coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ entry.entry_id ][DATA_COORDINATOR] async_add_entities([ResponseBinarySensor(coordinator, client, entry)]) -class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): +class ResponseBinarySensor( + CoordinatorEntity[FireServiceUpdateCoordinator], BinarySensorEntity +): """Representation of an FireServiceRota sensor.""" _attr_has_entity_name = True @@ -41,7 +40,7 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: FireServiceUpdateCoordinator, client: FireServiceRotaClient, entry: ConfigEntry, ) -> None: diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py new file mode 100644 index 00000000000..35f839b3bdb --- /dev/null +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -0,0 +1,213 @@ +"""The FireServiceRota integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pyfireservicerota import ( + ExpiredTokenError, + FireServiceRota, + FireServiceRotaIncidents, + InvalidAuthError, + InvalidTokenError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, WSS_BWRURL + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + + +class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]): + """Data update coordinator for FireServiceRota.""" + + def __init__( + self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry + ) -> None: + """Initialize the FireServiceRota DataUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="duty binary sensor", + config_entry=entry, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + self.client = client + + async def _async_update_data(self) -> dict | None: + """Get the latest availability data.""" + return await self.client.async_update() + + +class FireServiceRotaOauth: + """Handle authentication tokens.""" + + def __init__(self, hass, entry, fsr): + """Initialize the oauth object.""" + self._hass = hass + self._entry = entry + + self._url = entry.data[CONF_URL] + self._username = entry.data[CONF_USERNAME] + self._fsr = fsr + + async def async_refresh_tokens(self) -> bool: + """Refresh tokens and update config entry.""" + _LOGGER.debug("Refreshing authentication tokens after expiration") + + try: + token_info = await self._hass.async_add_executor_job( + self._fsr.refresh_tokens + ) + + except (InvalidAuthError, InvalidTokenError) as err: + raise ConfigEntryAuthFailed( + "Error refreshing tokens, triggered reauth workflow" + ) from err + + _LOGGER.debug("Saving new tokens in config entry") + self._hass.config_entries.async_update_entry( + self._entry, + data={ + "auth_implementation": DOMAIN, + CONF_URL: self._url, + CONF_USERNAME: self._username, + CONF_TOKEN: token_info, + }, + ) + + return True + + +class FireServiceRotaWebSocket: + """Define a FireServiceRota websocket manager object.""" + + def __init__(self, hass, entry): + """Initialize the websocket object.""" + self._hass = hass + self._entry = entry + + self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident) + self.incident_data = None + + def _construct_url(self) -> str: + """Return URL with latest access token.""" + return WSS_BWRURL.format( + self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"] + ) + + def _on_incident(self, data) -> None: + """Received new incident, update data.""" + _LOGGER.debug("Received new incident via websocket: %s", data) + self.incident_data = data + dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update") + + def start_listener(self) -> None: + """Start the websocket listener.""" + _LOGGER.debug("Starting incidents listener") + self._fsr_incidents.start(self._construct_url()) + + def stop_listener(self) -> None: + """Stop the websocket listener.""" + _LOGGER.debug("Stopping incidents listener") + self._fsr_incidents.stop() + + +class FireServiceRotaClient: + """Getting the latest data from fireservicerota.""" + + def __init__(self, hass, entry): + """Initialize the data object.""" + self._hass = hass + self._entry = entry + + self._url = entry.data[CONF_URL] + self._tokens = entry.data[CONF_TOKEN] + + self.entry_id = entry.entry_id + self.unique_id = entry.unique_id + + self.token_refresh_failure = False + self.incident_id = None + self.on_duty = False + + self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens) + + self.oauth = FireServiceRotaOauth( + self._hass, + self._entry, + self.fsr, + ) + + self.websocket = FireServiceRotaWebSocket(self._hass, self._entry) + + async def setup(self) -> None: + """Set up the data client.""" + await self._hass.async_add_executor_job(self.websocket.start_listener) + + async def update_call(self, func, *args): + """Perform update call and return data.""" + if self.token_refresh_failure: + return None + + try: + return await self._hass.async_add_executor_job(func, *args) + except (ExpiredTokenError, InvalidTokenError): + await self._hass.async_add_executor_job(self.websocket.stop_listener) + self.token_refresh_failure = True + + if await self.oauth.async_refresh_tokens(): + self.token_refresh_failure = False + await self._hass.async_add_executor_job(self.websocket.start_listener) + + return await self._hass.async_add_executor_job(func, *args) + + async def async_update(self) -> dict | None: + """Get the latest availability data.""" + data = await self.update_call( + self.fsr.get_availability, str(self._hass.config.time_zone) + ) + + if not data: + return None + + self.on_duty = bool(data.get("available")) + + _LOGGER.debug("Updated availability data: %s", data) + return data + + async def async_response_update(self) -> dict | None: + """Get the latest incident response data.""" + + if not self.incident_id: + return None + + _LOGGER.debug("Updating response data for incident id %s", self.incident_id) + + return await self.update_call(self.fsr.get_incident_response, self.incident_id) + + async def async_set_response(self, value) -> None: + """Set incident response status.""" + + if not self.incident_id: + return + + _LOGGER.debug( + "Setting incident response for incident id '%s' to state '%s'", + self.incident_id, + value, + ) + + await self.update_call(self.fsr.set_incident_response, self.incident_id, value) diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json index 7826115fa3f..945ef141887 100644 --- a/homeassistant/components/fireservicerota/manifest.json +++ b/homeassistant/components/fireservicerota/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/fireservicerota", "iot_class": "cloud_polling", "loggers": ["pyfireservicerota"], - "requirements": ["pyfireservicerota==0.0.43"] + "requirements": ["pyfireservicerota==0.0.46"] } diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 22d0e302d63..0c4a37198d6 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -7,7 +7,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from . import api -from .const import DOMAIN, FitbitScope +from .const import FitbitScope from .coordinator import FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import config_from_entry_data @@ -15,10 +15,11 @@ from .model import config_from_entry_data PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up fitbit from a config entry.""" - hass.data.setdefault(DOMAIN, {}) +type FitbitConfigEntry = ConfigEntry[FitbitData] + +async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: + """Set up fitbit from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -41,18 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = FitbitDeviceCoordinator(hass, fitbit_api) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = FitbitData( - api=fitbit_api, device_coordinator=coordinator - ) + entry.runtime_data = FitbitData(api=fitbit_api, device_coordinator=coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d58dad4ca67..4ccbea97a66 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -29,9 +28,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FitbitConfigEntry from .api import FitbitApi from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem -from .coordinator import FitbitData, FitbitDeviceCoordinator +from .coordinator import FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, config_from_entry_data @@ -131,7 +131,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): def _build_device_info( - config_entry: ConfigEntry, entity_description: FitbitSensorEntityDescription + config_entry: FitbitConfigEntry, entity_description: FitbitSensorEntityDescription ) -> DeviceInfo: """Build device info for sensor entities info across devices.""" unique_id = cast(str, config_entry.unique_id) @@ -524,12 +524,12 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FitbitConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fitbit sensor platform.""" - data: FitbitData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data api = data.api # These are run serially to reuse the cached user profile, not gathered @@ -601,7 +601,7 @@ class FitbitSensor(SensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: FitbitConfigEntry, api: FitbitApi, user_profile_id: str, description: FitbitSensorEntityDescription, diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 09df989447a..9643f333bb5 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -2,7 +2,6 @@ from libpyfoscam import FoscamCamera -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -14,13 +13,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .config_flow import DEFAULT_RTSP_PORT -from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET -from .coordinator import FoscamCoordinator +from .const import CONF_RTSP_PORT, LOGGER +from .coordinator import FoscamConfigEntry, FoscamCoordinator PLATFORMS = [Platform.CAMERA, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: """Set up foscam from a config entry.""" session = FoscamCamera( @@ -30,11 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], verbose=False, ) - coordinator = FoscamCoordinator(hass, session) + coordinator = FoscamCoordinator(hass, entry, session) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator # Migrate to correct unique IDs for switches await async_migrate_entities(hass, entry) @@ -44,20 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - if not hass.data[DOMAIN]: - hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ) - hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ_PRESET) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool: """Migrate old entry.""" LOGGER.debug("Migrating from version %s", entry.version) @@ -97,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_migrate_entities(hass: HomeAssistant, entry: FoscamConfigEntry) -> None: """Migrate old entry.""" @callback diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 075848f6ffb..ed5ba1d4c21 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -7,21 +7,13 @@ import asyncio import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_RTSP_PORT, - CONF_STREAM, - DOMAIN, - LOGGER, - SERVICE_PTZ, - SERVICE_PTZ_PRESET, -) -from .coordinator import FoscamCoordinator +from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .coordinator import FoscamConfigEntry, FoscamCoordinator from .entity import FoscamEntity DIR_UP = "up" @@ -56,7 +48,7 @@ PTZ_GOTO_PRESET_COMMAND = "ptz_goto_preset" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a Foscam IP camera from a config entry.""" @@ -89,7 +81,7 @@ async def async_setup_entry( "async_perform_ptz_preset", ) - coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities([HassFoscamCamera(coordinator, config_entry)]) @@ -103,7 +95,7 @@ class HassFoscamCamera(FoscamEntity, Camera): def __init__( self, coordinator: FoscamCoordinator, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, ) -> None: """Initialize a Foscam camera.""" super().__init__(coordinator, config_entry.entry_id) diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index e7a8abf7d30..92eb7615e2a 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -6,11 +6,14 @@ from typing import Any from libpyfoscam import FoscamCamera +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER +type FoscamConfigEntry = ConfigEntry[FoscamCoordinator] + class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Foscam coordinator.""" @@ -18,12 +21,14 @@ class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): def __init__( self, hass: HomeAssistant, + entry: FoscamConfigEntry, session: FoscamCamera, ) -> None: """Initialize my coordinator.""" super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index dfc51aaa064..189271d2746 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -5,24 +5,23 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FoscamCoordinator -from .const import DOMAIN, LOGGER +from .const import LOGGER +from .coordinator import FoscamConfigEntry, FoscamCoordinator from .entity import FoscamEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up foscam switch from a config entry.""" - coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data await coordinator.async_config_entry_first_refresh() @@ -36,7 +35,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity): def __init__( self, coordinator: FoscamCoordinator, - config_entry: ConfigEntry, + config_entry: FoscamConfigEntry, ) -> None: """Initialize a Foscam Sleep Switch.""" super().__init__(coordinator, config_entry.entry_id) diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py index c14c2f5ae36..9ce7701216c 100644 --- a/homeassistant/components/freedompro/__init__.py +++ b/homeassistant/components/freedompro/__init__.py @@ -2,17 +2,12 @@ from __future__ import annotations -import logging from typing import Final -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator PLATFORMS: Final[list[Platform]] = [ Platform.BINARY_SENSOR, @@ -26,32 +21,27 @@ PLATFORMS: Final[list[Platform]] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FreedomproConfigEntry) -> bool: """Set up Freedompro from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - api_key = entry.data[CONF_API_KEY] - - coordinator = FreedomproDataUpdateCoordinator(hass, api_key) + coordinator = FreedomproDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FreedomproConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: FreedomproConfigEntry +) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py index ccea5faf41f..840150e807d 100644 --- a/homeassistant/components/freedompro/binary_sensor.py +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -6,14 +6,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "smokeSensor": BinarySensorDeviceClass.SMOKE, @@ -33,10 +32,12 @@ SUPPORTED_SENSORS = {"smokeSensor", "occupancySensor", "motionSensor", "contactS async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro binary_sensor.""" - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index a5b0144ce0c..a0146dc70b3 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -15,7 +15,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -24,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -44,11 +43,13 @@ SUPPORTED_HVAC_MODES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro climate.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device( aiohttp_client.async_get_clientsession(hass), api_key, device, coordinator diff --git a/homeassistant/components/freedompro/coordinator.py b/homeassistant/components/freedompro/coordinator.py index ad76a9aaa65..23b181b2655 100644 --- a/homeassistant/components/freedompro/coordinator.py +++ b/homeassistant/components/freedompro/coordinator.py @@ -8,6 +8,9 @@ from typing import Any from pyfreedompro import get_list, get_states +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,18 +18,27 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type FreedomproConfigEntry = ConfigEntry[FreedomproDataUpdateCoordinator] + class FreedomproDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Class to manage fetching Freedompro data API.""" - def __init__(self, hass, api_key): + def __init__(self, hass: HomeAssistant, entry: FreedomproConfigEntry) -> None: """Initialize.""" + self._hass = hass - self._api_key = api_key + self._api_key = entry.data[CONF_API_KEY] self._devices: list[dict[str, Any]] | None = None update_interval = timedelta(minutes=1) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self): if self._devices is None: diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index 06ad5c80b6a..ee61612428c 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -11,7 +11,6 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "windowCovering": CoverDeviceClass.BLIND, @@ -34,11 +33,13 @@ SUPPORTED_SENSORS = {"windowCovering", "gate", "garageDoor", "door", "window"} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro cover.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index d21ede9bad3..ad520ac8eb8 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -8,7 +8,6 @@ from typing import Any from pyfreedompro import put_state from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -17,15 +16,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro fan.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( FreedomproFan(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index ab8df7ec9db..c1b2e0ea17b 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -13,7 +13,6 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -22,15 +21,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro light.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index c429ef6aa99..70423bb9514 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -6,7 +6,6 @@ from typing import Any from pyfreedompro import put_state from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -15,15 +14,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro lock.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index 3c5101e3634..eaa96ac9fed 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -7,7 +7,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -15,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator DEVICE_CLASS_MAP = { "temperatureSensor": SensorDeviceClass.TEMPERATURE, @@ -41,10 +40,12 @@ SUPPORTED_SENSORS = {"temperatureSensor", "humiditySensor", "lightSensor"} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro sensor.""" - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py index 91e67506173..12346825474 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -6,7 +6,6 @@ from typing import Any from pyfreedompro import put_state from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client @@ -15,15 +14,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import FreedomproDataUpdateCoordinator +from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FreedomproConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Freedompro switch.""" api_key: str = entry.data[CONF_API_KEY] - coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b584fe5e2f0..d27785dcea5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250204.0"] + "requirements": ["home-assistant-frontend==20250205.0"] } diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index 40633537ddf..238c145302a 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "local_polling", "loggers": ["ismartgate"], - "requirements": ["ismartgate==5.0.1"] + "requirements": ["ismartgate==5.0.2"] } diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index bd60e8d94c1..4d83b935528 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -8,7 +8,7 @@ CONF_PROMPT = "prompt" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest" +RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 4d871a991a6..1c61ae31010 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -38,6 +38,10 @@ "local_name": "GV5126*", "connectable": false }, + { + "local_name": "GV5179*", + "connectable": false + }, { "local_name": "GVH5127*", "connectable": false @@ -131,5 +135,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.42.1"] + "requirements": ["govee-ble==0.43.0"] } diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index cb2e24fa8a6..c7799a7ffc4 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from govee_local_api import GoveeDevice, GoveeLightCapability +from govee_local_api import GoveeDevice, GoveeLightFeatures from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -71,13 +71,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): capabilities = device.capabilities color_modes = {ColorMode.ONOFF} if capabilities: - if GoveeLightCapability.COLOR_RGB in capabilities: + if GoveeLightFeatures.COLOR_RGB & capabilities.features: color_modes.add(ColorMode.RGB) - if GoveeLightCapability.COLOR_KELVIN_TEMPERATURE in capabilities: + if GoveeLightFeatures.COLOR_KELVIN_TEMPERATURE & capabilities.features: color_modes.add(ColorMode.COLOR_TEMP) self._attr_max_color_temp_kelvin = 9000 self._attr_min_color_temp_kelvin = 2000 - if GoveeLightCapability.BRIGHTNESS in capabilities: + if GoveeLightFeatures.BRIGHTNESS & capabilities.features: color_modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(color_modes) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index a94d4e58e9a..cba341cd482 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==1.5.3"] + "requirements": ["govee-local-api==2.0.1"] } diff --git a/homeassistant/components/gpsd/icons.json b/homeassistant/components/gpsd/icons.json index 59d904f918c..3605bdc6d70 100644 --- a/homeassistant/components/gpsd/icons.json +++ b/homeassistant/components/gpsd/icons.json @@ -16,6 +16,12 @@ }, "elevation": { "default": "mdi:arrow-up-down" + }, + "total_satellites": { + "default": "mdi:satellite-variant" + }, + "used_satellites": { + "default": "mdi:satellite-variant" } } } diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 1bac41ecaae..86d3ab7cc04 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import logging -from typing import Any from gps3.agps3threaded import AGPS3mechanism @@ -14,6 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( ATTR_LATITUDE, @@ -37,14 +37,32 @@ _LOGGER = logging.getLogger(__name__) ATTR_CLIMB = "climb" ATTR_ELEVATION = "elevation" -ATTR_GPS_TIME = "gps_time" ATTR_SPEED = "speed" +ATTR_TOTAL_SATELLITES = "total_satellites" +ATTR_USED_SATELLITES = "used_satellites" DEFAULT_NAME = "GPS" _MODE_VALUES = {2: "2d_fix", 3: "3d_fix"} +def count_total_satellites_fn(agps_thread: AGPS3mechanism) -> int | None: + """Count the number of total satellites.""" + satellites = agps_thread.data_stream.satellites + return None if satellites == "n/a" else len(satellites) + + +def count_used_satellites_fn(agps_thread: AGPS3mechanism) -> int | None: + """Count the number of used satellites.""" + satellites = agps_thread.data_stream.satellites + if satellites == "n/a": + return None + + return sum( + 1 for sat in satellites if isinstance(sat, dict) and sat.get("used", False) + ) + + @dataclass(frozen=True, kw_only=True) class GpsdSensorDescription(SensorEntityDescription): """Class describing GPSD sensor entities.""" @@ -116,6 +134,22 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = ( suggested_display_precision=2, entity_registry_enabled_default=False, ), + GpsdSensorDescription( + key=ATTR_TOTAL_SATELLITES, + translation_key=ATTR_TOTAL_SATELLITES, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=count_total_satellites_fn, + entity_registry_enabled_default=False, + ), + GpsdSensorDescription( + key=ATTR_USED_SATELLITES, + translation_key=ATTR_USED_SATELLITES, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=count_used_satellites_fn, + entity_registry_enabled_default=False, + ), ) @@ -165,21 +199,3 @@ class GpsdSensor(SensorEntity): """Return the state of GPSD.""" value = self.entity_description.value_fn(self.agps_thread) return None if value == "n/a" else value - - # Deprecated since Home Assistant 2024.9.0 - # Can be removed completely in 2025.3.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the GPS.""" - if self.entity_description.key != ATTR_MODE: - return None - - return { - ATTR_LATITUDE: self.agps_thread.data_stream.lat, - ATTR_LONGITUDE: self.agps_thread.data_stream.lon, - ATTR_ELEVATION: self.agps_thread.data_stream.alt, - ATTR_GPS_TIME: self.agps_thread.data_stream.time, - ATTR_SPEED: self.agps_thread.data_stream.speed, - ATTR_CLIMB: self.agps_thread.data_stream.climb, - ATTR_MODE: self.agps_thread.data_stream.mode, - } diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json index 867edf0b5a8..a5d6c570b54 100644 --- a/homeassistant/components/gpsd/strings.json +++ b/homeassistant/components/gpsd/strings.json @@ -50,6 +50,14 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]" } } + }, + "total_satellites": { + "name": "Total satellites", + "unit_of_measurement": "satellites" + }, + "used_satellites": { + "name": "Used satellites", + "unit_of_measurement": "satellites" } } } diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 6ace6d45509..9ea346a0dcb 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.4"] + "requirements": ["habiticalib==0.3.5"] } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index a28aada85fa..ed4a6444ea2 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -510,7 +510,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 or (task.notes and keyword in task.notes.lower()) or any(keyword in item.text.lower() for item in task.checklist) ] - result: dict[str, Any] = {"tasks": response} + result: dict[str, Any] = {"tasks": [task.to_dict() for task in response]} + return result hass.services.async_register( diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 142c5fc01ce..ddaa821587f 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -20,6 +20,7 @@ from aiohasupervisor.models import ( backups as supervisor_backups, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from homeassistant.components.backup import ( DATA_MANAGER, @@ -56,8 +57,6 @@ from homeassistant.util.enum import try_parse_enum from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .handler import get_supervisor_client -LOCATION_CLOUD_BACKUP = ".cloud_backup" -LOCATION_LOCAL = ".local" MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" # Set on backups automatically created when updating an addon @@ -72,7 +71,9 @@ async def async_get_backup_agents( """Return the hassio backup agents.""" client = get_supervisor_client(hass) mounts = await client.mounts.info() - agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)] + agents: list[BackupAgent] = [ + SupervisorBackupAgent(hass, "local", LOCATION_LOCAL_STORAGE) + ] for mount in mounts.mounts: if mount.usage is not supervisor_mounts.MountUsage.BACKUP: continue @@ -112,7 +113,7 @@ def async_register_backup_agents_listener( def _backup_details_to_agent_backup( - details: supervisor_backups.BackupComplete, location: str | None + details: supervisor_backups.BackupComplete, location: str ) -> AgentBackup: """Convert a supervisor backup details object to an agent backup.""" homeassistant_included = details.homeassistant is not None @@ -125,7 +126,6 @@ def _backup_details_to_agent_backup( for addon in details.addons ] extra_metadata = details.extra or {} - location = location or LOCATION_LOCAL return AgentBackup( addons=addons, backup_id=details.slug, @@ -148,7 +148,7 @@ class SupervisorBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None: + def __init__(self, hass: HomeAssistant, name: str, location: str) -> None: """Initialize the backup agent.""" super().__init__() self._hass = hass @@ -206,7 +206,7 @@ class SupervisorBackupAgent(BackupAgent): backup_list = await self._client.backups.list() result = [] for backup in backup_list: - if not backup.locations or self.location not in backup.locations: + if self.location not in backup.location_attributes: continue details = await self._client.backups.backup_info(backup.slug) result.append(_backup_details_to_agent_backup(details, self.location)) @@ -222,7 +222,7 @@ class SupervisorBackupAgent(BackupAgent): details = await self._client.backups.backup_info(backup_id) except SupervisorNotFoundError: return None - if self.location not in details.locations: + if self.location not in details.location_attributes: return None return _backup_details_to_agent_backup(details, self.location) @@ -295,8 +295,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): # will be handled by async_upload_backup. # If the lists are the same length, it does not matter which one we send, # we send the encrypted list to have a well defined behavior. - encrypted_locations: list[str | None] = [] - decrypted_locations: list[str | None] = [] + encrypted_locations: list[str] = [] + decrypted_locations: list[str] = [] agents_settings = manager.config.data.agents for hassio_agent in hassio_agents: if password is not None: @@ -353,12 +353,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): eager_start=False, # To ensure the task is not started before we return ) - return (NewBackup(backup_job_id=backup.job_id), backup_task) + return (NewBackup(backup_job_id=backup.job_id.hex), backup_task) async def _async_wait_for_backup( self, backup: supervisor_backups.NewBackup, - locations: list[str | None], + locations: list[str], *, on_progress: Callable[[CreateBackupEvent], None], remove_after_upload: bool, @@ -508,7 +508,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): else None ) - restore_location: str | None + restore_location: str if manager.backup_agents[agent_id].domain != DOMAIN: # Download the backup to the supervisor. Supervisor will clean up the backup # two days after the restore is done. @@ -577,10 +577,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], ) -> None: """Check restore status after core restart.""" - if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): + if not (restore_job_str := os.environ.get(RESTORE_JOB_ID_ENV)): _LOGGER.debug("No restore job ID found in environment") return + restore_job_id = UUID(restore_job_str) _LOGGER.debug("Found restore job ID %s in environment", restore_job_id) sent_event = False @@ -634,7 +635,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): @callback def _async_listen_job_events( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> Callable[[], None]: """Listen for job events.""" @@ -649,7 +650,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): if ( data.get("event") != "job" or not (event_data := data.get("data")) - or event_data.get("uuid") != job_id + or event_data.get("uuid") != job_id.hex ): return on_event(event_data) @@ -660,10 +661,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): return unsub async def _get_job_state( - self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] + self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] ) -> None: """Poll a job for its state.""" - job = await self._client.jobs.get_job(UUID(job_id)) + job = await self._client.jobs.get_job(job_id) _LOGGER.debug("Job state: %s", job) on_event(job.to_dict()) diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 2d39e740e63..833068a713c 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -295,6 +295,8 @@ def async_remove_addons_from_dev_reg( class HassioDataUpdateCoordinator(DataUpdateCoordinator): """Class to retrieve Hass.io status.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry ) -> None: @@ -302,6 +304,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=HASSIO_UPDATE_INTERVAL, # We don't want an immediate refresh since we want to avoid diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index ccc0f23fb43..ad98beb5baa 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.2b6"], + "requirements": ["aiohasupervisor==0.3.0"], "single_config_entry": true } diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index dc8989fd55b..94aa4ad0ab5 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -82,12 +82,20 @@ class HeosCoordinator(DataUpdateCoordinator[None]): try: await self.heos.connect() except HeosError as error: - raise ConfigEntryNotReady from error + _LOGGER.debug("Unable to connect to %s", self.host, exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="unable_to_connect", + translation_placeholders={"host": self.host}, + ) from error # Load players try: await self.heos.get_players() except HeosError as error: - raise ConfigEntryNotReady from error + _LOGGER.debug("Unexpected error retrieving players", exc_info=True) + raise ConfigEntryNotReady( + translation_domain=DOMAIN, translation_key="unable_to_get_players" + ) from error if not self.heos.is_signed_in: _LOGGER.warning( diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index f5066d0a743..67022ec492c 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -54,7 +54,7 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: done repair-issues: todo diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 4092d4360db..53e20a032b5 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -112,6 +112,12 @@ "not_heos_media_player": { "message": "Entity {entity_id} is not a HEOS media player entity" }, + "unable_to_connect": { + "message": "Unable to connect to {host}" + }, + "unable_to_get_players": { + "message": "Unexpected error retrieving players" + }, "unknown_source": { "message": "Unknown source: {source}" } diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index cfa14a3e3ca..c0534fa7154 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -5,11 +5,11 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", - "mode": "Travel Mode" + "mode": "Travel mode" } }, "origin_menu": { - "title": "Choose Origin", + "title": "Choose origin", "menu_options": { "origin_coordinates": "Using a map location", "origin_entity": "Using an entity" @@ -28,7 +28,7 @@ } }, "destination_menu": { - "title": "Choose Destination", + "title": "Choose destination", "menu_options": { "destination_coordinates": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_coordinates%]", "destination_entity": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_entity%]" @@ -60,13 +60,13 @@ "step": { "init": { "data": { - "traffic_mode": "Traffic Mode", - "route_mode": "Route Mode", + "traffic_mode": "Traffic mode", + "route_mode": "Route mode", "unit_system": "Unit system" } }, "time_menu": { - "title": "Choose Time Type", + "title": "Choose time type", "menu_options": { "departure_time": "Configure a departure time", "arrival_time": "Configure an arrival time", @@ -74,15 +74,15 @@ } }, "departure_time": { - "title": "Choose Departure Time", + "title": "Choose departure time", "data": { - "departure_time": "Departure Time" + "departure_time": "Departure time" } }, "arrival_time": { - "title": "Choose Arrival Time", + "title": "Choose arrival time", "data": { - "arrival_time": "Arrival Time" + "arrival_time": "Arrival time" } } } diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index edf3ebe7f04..6952d48ef32 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.65", "babel==2.15.0"] + "requirements": ["holidays==0.66", "babel==2.15.0"] } diff --git a/homeassistant/components/homeassistant_hardware/__init__.py b/homeassistant/components/homeassistant_hardware/__init__.py index c33dabe1ec8..fc2b393805e 100644 --- a/homeassistant/components/homeassistant_hardware/__init__.py +++ b/homeassistant/components/homeassistant_hardware/__init__.py @@ -6,10 +6,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -DOMAIN = "homeassistant_hardware" +from .const import DATA_COMPONENT, DOMAIN +from .helpers import HardwareInfoDispatcher + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" + + hass.data[DATA_COMPONENT] = HardwareInfoDispatcher(hass) + return True diff --git a/homeassistant/components/homeassistant_hardware/const.py b/homeassistant/components/homeassistant_hardware/const.py index 8fddbe41b7d..a3c091ff7ee 100644 --- a/homeassistant/components/homeassistant_hardware/const.py +++ b/homeassistant/components/homeassistant_hardware/const.py @@ -1,10 +1,23 @@ """Constants for the Homeassistant Hardware integration.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .helpers import HardwareInfoDispatcher + LOGGER = logging.getLogger(__package__) +DOMAIN = "homeassistant_hardware" +DATA_COMPONENT: HassKey[HardwareInfoDispatcher] = HassKey(DOMAIN) + ZHA_DOMAIN = "zha" +OTBR_DOMAIN = "otbr" OTBR_ADDON_NAME = "OpenThread Border Router" OTBR_ADDON_MANAGER_DATA = "openthread_border_router" diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index fac3d2d9735..8d7a302e786 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -25,12 +25,14 @@ from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers.hassio import is_hassio from . import silabs_multiprotocol_addon -from .const import ZHA_DOMAIN +from .const import OTBR_DOMAIN, ZHA_DOMAIN from .util import ( ApplicationType, + OwningAddon, + OwningIntegration, get_otbr_addon_manager, - get_zha_device_path, get_zigbee_flasher_addon_manager, + guess_hardware_owners, probe_silabs_firmware_type, ) @@ -519,19 +521,15 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow): ) -> ConfigFlowResult: """Pick Zigbee firmware.""" assert self._device is not None + owners = await guess_hardware_owners(self.hass, self._device) - if is_hassio(self.hass): - otbr_manager = get_otbr_addon_manager(self.hass) - otbr_addon_info = await self._async_get_addon_info(otbr_manager) - - if ( - otbr_addon_info.state != AddonState.NOT_INSTALLED - and otbr_addon_info.options.get("device") == self._device - ): - raise AbortFlow( - "otbr_still_using_stick", - description_placeholders=self._get_translation_placeholders(), - ) + for info in owners: + for owner in info.owners: + if info.source == OTBR_DOMAIN and isinstance(owner, OwningAddon): + raise AbortFlow( + "otbr_still_using_stick", + description_placeholders=self._get_translation_placeholders(), + ) return await super().async_step_pick_firmware_zigbee(user_input) @@ -541,15 +539,14 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow): """Pick Thread firmware.""" assert self._device is not None - for zha_entry in self.hass.config_entries.async_entries( - ZHA_DOMAIN, - include_ignore=False, - include_disabled=True, - ): - if get_zha_device_path(zha_entry) == self._device: - raise AbortFlow( - "zha_still_using_stick", - description_placeholders=self._get_translation_placeholders(), - ) + owners = await guess_hardware_owners(self.hass, self._device) + + for info in owners: + for owner in info.owners: + if info.source == ZHA_DOMAIN and isinstance(owner, OwningIntegration): + raise AbortFlow( + "zha_still_using_stick", + description_placeholders=self._get_translation_placeholders(), + ) return await super().async_step_pick_firmware_thread(user_input) diff --git a/homeassistant/components/homeassistant_hardware/helpers.py b/homeassistant/components/homeassistant_hardware/helpers.py new file mode 100644 index 00000000000..a9b3703ee4a --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/helpers.py @@ -0,0 +1,143 @@ +"""Home Assistant Hardware integration helpers.""" + +from collections import defaultdict +from collections.abc import AsyncIterator, Awaitable, Callable +import logging +from typing import Protocol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback + +from . import DATA_COMPONENT +from .util import FirmwareInfo + +_LOGGER = logging.getLogger(__name__) + + +class SyncHardwareFirmwareInfoModule(Protocol): + """Protocol type for Home Assistant Hardware firmware info platform modules.""" + + def get_firmware_info( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> FirmwareInfo | None: + """Return radio firmware information for the config entry, synchronously.""" + + +class AsyncHardwareFirmwareInfoModule(Protocol): + """Protocol type for Home Assistant Hardware firmware info platform modules.""" + + async def async_get_firmware_info( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> FirmwareInfo | None: + """Return radio firmware information for the config entry, asynchronously.""" + + +type HardwareFirmwareInfoModule = ( + SyncHardwareFirmwareInfoModule | AsyncHardwareFirmwareInfoModule +) + + +class HardwareInfoDispatcher: + """Central dispatcher for hardware/firmware information.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the dispatcher.""" + self.hass = hass + self._providers: dict[str, HardwareFirmwareInfoModule] = {} + self._notification_callbacks: defaultdict[ + str, set[Callable[[FirmwareInfo], None]] + ] = defaultdict(set) + + def register_firmware_info_provider( + self, domain: str, platform: HardwareFirmwareInfoModule + ) -> None: + """Register a firmware info provider.""" + if domain in self._providers: + raise ValueError( + f"Domain {domain} is already registered as a firmware info provider" + ) + + # There is no need to handle "unregistration" because integrations cannot be + # wholly removed at runtime + self._providers[domain] = platform + _LOGGER.debug( + "Registered firmware info provider from domain %r: %s", domain, platform + ) + + def register_firmware_info_callback( + self, device: str, callback: Callable[[FirmwareInfo], None] + ) -> CALLBACK_TYPE: + """Register a firmware info notification callback.""" + self._notification_callbacks[device].add(callback) + + @hass_callback + def async_remove_callback() -> None: + self._notification_callbacks[device].discard(callback) + + return async_remove_callback + + async def notify_firmware_info( + self, domain: str, firmware_info: FirmwareInfo + ) -> None: + """Notify the dispatcher of new firmware information.""" + _LOGGER.debug( + "Received firmware info notification from %r: %s", domain, firmware_info + ) + + for callback in self._notification_callbacks.get(firmware_info.device, []): + try: + callback(firmware_info) + except Exception: + _LOGGER.exception( + "Error while notifying firmware info listener %s", callback + ) + + async def iter_firmware_info(self) -> AsyncIterator[FirmwareInfo]: + """Iterate over all firmware information for all hardware.""" + for domain, fw_info_module in self._providers.items(): + for config_entry in self.hass.config_entries.async_entries(domain): + try: + if hasattr(fw_info_module, "get_firmware_info"): + fw_info = fw_info_module.get_firmware_info( + self.hass, config_entry + ) + else: + fw_info = await fw_info_module.async_get_firmware_info( + self.hass, config_entry + ) + except Exception: + _LOGGER.exception( + "Error while getting firmware info from %r", fw_info_module + ) + continue + + if fw_info is not None: + yield fw_info + + +@hass_callback +def async_register_firmware_info_provider( + hass: HomeAssistant, domain: str, platform: HardwareFirmwareInfoModule +) -> None: + """Register a firmware info provider.""" + return hass.data[DATA_COMPONENT].register_firmware_info_provider(domain, platform) + + +@hass_callback +def async_register_firmware_info_callback( + hass: HomeAssistant, device: str, callback: Callable[[FirmwareInfo], None] +) -> CALLBACK_TYPE: + """Register a firmware info provider.""" + return hass.data[DATA_COMPONENT].register_firmware_info_callback(device, callback) + + +@hass_callback +def async_notify_firmware_info( + hass: HomeAssistant, domain: str, firmware_info: FirmwareInfo +) -> Awaitable[None]: + """Notify the dispatcher of new firmware information.""" + return hass.data[DATA_COMPONENT].notify_firmware_info(domain, firmware_info) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 3fd5bc60037..53cbcbae5d4 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -2,27 +2,27 @@ from __future__ import annotations +import asyncio from collections import defaultdict from collections.abc import Iterable from dataclasses import dataclass from enum import StrEnum import logging -from typing import cast from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.flasher import Flasher from homeassistant.components.hassio import AddonError, AddonState -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton +from . import DATA_COMPONENT from .const import ( OTBR_ADDON_MANAGER_DATA, OTBR_ADDON_NAME, OTBR_ADDON_SLUG, - ZHA_DOMAIN, ZIGBEE_FLASHER_ADDON_MANAGER_DATA, ZIGBEE_FLASHER_ADDON_NAME, ZIGBEE_FLASHER_ADDON_SLUG, @@ -55,11 +55,6 @@ class ApplicationType(StrEnum): return FlasherApplicationType(self.value) -def get_zha_device_path(config_entry: ConfigEntry) -> str | None: - """Get the device path from a ZHA config entry.""" - return cast(str | None, config_entry.data.get("device", {}).get("path", None)) - - @singleton(OTBR_ADDON_MANAGER_DATA) @callback def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager: @@ -84,31 +79,80 @@ def get_zigbee_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager ) -@dataclass(slots=True, kw_only=True) -class FirmwareGuess: +@dataclass(kw_only=True) +class OwningAddon: + """Owning add-on.""" + + slug: str + + def _get_addon_manager(self, hass: HomeAssistant) -> WaitingAddonManager: + return WaitingAddonManager( + hass, + _LOGGER, + f"Add-on {self.slug}", + self.slug, + ) + + async def is_running(self, hass: HomeAssistant) -> bool: + """Check if the add-on is running.""" + addon_manager = self._get_addon_manager(hass) + + try: + addon_info = await addon_manager.async_get_addon_info() + except AddonError: + return False + else: + return addon_info.state == AddonState.RUNNING + + +@dataclass(kw_only=True) +class OwningIntegration: + """Owning integration.""" + + config_entry_id: str + + async def is_running(self, hass: HomeAssistant) -> bool: + """Check if the integration is running.""" + if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None: + return False + + return entry.state in ( + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, + ) + + +@dataclass(kw_only=True) +class FirmwareInfo: """Firmware guess.""" - is_running: bool + device: str firmware_type: ApplicationType + firmware_version: str | None + source: str + owners: list[OwningAddon | OwningIntegration] + + async def is_running(self, hass: HomeAssistant) -> bool: + """Check if the firmware owner is running.""" + states = await asyncio.gather(*(o.is_running(hass) for o in self.owners)) + if not states: + return False + + return all(states) -async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> FirmwareGuess: - """Guess the firmware type based on installed addons and other integrations.""" - device_guesses: defaultdict[str | None, list[FirmwareGuess]] = defaultdict(list) +async def guess_hardware_owners( + hass: HomeAssistant, device_path: str +) -> list[FirmwareInfo]: + """Guess the firmware info based on installed addons and other integrations.""" + device_guesses: defaultdict[str, list[FirmwareInfo]] = defaultdict(list) - for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN): - zha_path = get_zha_device_path(zha_config_entry) - - if zha_path is not None: - device_guesses[zha_path].append( - FirmwareGuess( - is_running=(zha_config_entry.state == ConfigEntryState.LOADED), - firmware_type=ApplicationType.EZSP, - source="zha", - ) - ) + async for firmware_info in hass.data[DATA_COMPONENT].iter_firmware_info(): + device_guesses[firmware_info.device].append(firmware_info) + # It may be possible for the OTBR addon to be present without the integration if is_hassio(hass): otbr_addon_manager = get_otbr_addon_manager(hass) @@ -119,14 +163,22 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware else: if otbr_addon_info.state != AddonState.NOT_INSTALLED: otbr_path = otbr_addon_info.options.get("device") - device_guesses[otbr_path].append( - FirmwareGuess( - is_running=(otbr_addon_info.state == AddonState.RUNNING), - firmware_type=ApplicationType.SPINEL, - source="otbr", - ) - ) + # Only create a new entry if there are no existing OTBR ones + if otbr_path is not None and not any( + info.source == "otbr" for info in device_guesses[otbr_path] + ): + device_guesses[otbr_path].append( + FirmwareInfo( + device=otbr_path, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)], + ) + ) + + if is_hassio(hass): multipan_addon_manager = await get_multiprotocol_addon_manager(hass) try: @@ -136,30 +188,48 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware else: if multipan_addon_info.state != AddonState.NOT_INSTALLED: multipan_path = multipan_addon_info.options.get("device") - device_guesses[multipan_path].append( - FirmwareGuess( - is_running=(multipan_addon_info.state == AddonState.RUNNING), - firmware_type=ApplicationType.CPC, - source="multiprotocol", - ) - ) - # Fall back to EZSP if we can't guess the firmware type - if device_path not in device_guesses: - return FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="unknown" + if multipan_path is not None: + device_guesses[multipan_path].append( + FirmwareInfo( + device=multipan_path, + firmware_type=ApplicationType.CPC, + firmware_version=None, + source="multiprotocol", + owners=[ + OwningAddon(slug=multipan_addon_manager.addon_slug) + ], + ) + ) + + return device_guesses.get(device_path, []) + + +async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> FirmwareInfo: + """Guess the firmware type based on installed addons and other integrations.""" + + hardware_owners = await guess_hardware_owners(hass, device_path) + + # Fall back to EZSP if we have no way to guess + if not hardware_owners: + return FirmwareInfo( + device=device_path, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[], ) - # Prioritizes guesses that were pulled from a running addon or integration but keep - # the sort order we defined above - guesses = sorted( - device_guesses[device_path], - key=lambda guess: guess.is_running, - ) - + # Prioritize guesses that are pulled from a real source + guesses = [ + (guess, sum([await owner.is_running(hass) for owner in guess.owners])) + for guess in hardware_owners + ] + guesses.sort(key=lambda p: p[1]) assert guesses - return guesses[-1] + # Pick the best one. We use a stable sort so ZHA < OTBR < multi-PAN + return guesses[-1][0] async def probe_silabs_firmware_type( diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 43d42e4fa59..758f0c1e1ef 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from homeassistant.components.homeassistant_hardware.util import guess_firmware_type +from homeassistant.components.homeassistant_hardware.util import guess_firmware_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -33,7 +33,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Add-on startup with type service get started before Core, always (e.g. the # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # so we can't safely probe here. Instead, we must make an educated guess! - firmware_guess = await guess_firmware_type( + firmware_guess = await guess_firmware_info( hass, config_entry.data["device"] ) diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index dc34cc4cdc9..b0837eeedbe 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -10,7 +10,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon ) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - guess_firmware_type, + guess_firmware_info, ) from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry from homeassistant.core import HomeAssistant @@ -75,7 +75,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Add-on startup with type service get started before Core, always (e.g. the # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # so we can't safely probe here. Instead, we must make an educated guess! - firmware_guess = await guess_firmware_type(hass, RADIO_DEVICE) + firmware_guess = await guess_firmware_info(hass, RADIO_DEVICE) new_data = {**config_entry.data} new_data[FIRMWARE] = firmware_guess.firmware_type.value diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 582c65f2838..f6f5588956c 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_VIA_DEVICE, PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, @@ -137,6 +138,21 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = ( else None ), ), + HomeWizardSensorEntityDescription( + key="wifi_rssi", + translation_key="wifi_rssi", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_fn=( + lambda data: data.system is not None + and data.system.wifi_rssi_db is not None + ), + value_fn=( + lambda data: data.system.wifi_rssi_db if data.system is not None else None + ), + ), HomeWizardSensorEntityDescription( key="total_power_import_kwh", translation_key="total_energy_import_kwh", diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 02b18d5fa4e..076e9375d24 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -78,6 +78,9 @@ "wifi_strength": { "name": "Wi-Fi strength" }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, "total_energy_import_kwh": { "name": "Energy import" }, diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index a107e2c5be4..231270d6eef 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -5,7 +5,7 @@ "title": "Connect to the PowerView Hub", "data": { "host": "[%key:common::config_flow::data::ip%]", - "api_version": "Hub Generation" + "api_version": "Hub generation" }, "data_description": { "api_version": "API version is detectable, but you can override and force a specific version" @@ -19,7 +19,7 @@ "flow_title": "{name} ({host})", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unsupported_device": "Only the primary powerview hub can be added", + "unsupported_device": "Only the primary PowerView Hub can be added", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index b517bf064e1..c6e5736bd2d 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -36,7 +36,7 @@ "entity": { "binary_sensor": { "jvc_power": { - "name": "[%key:component::sensor::entity_component::power::name%]" + "name": "[%key:component::binary_sensor::entity_component::power::name%]" } }, "select": { diff --git a/homeassistant/components/keba/strings.json b/homeassistant/components/keba/strings.json index ed8594a1068..49ce01f4332 100644 --- a/homeassistant/components/keba/strings.json +++ b/homeassistant/components/keba/strings.json @@ -2,7 +2,7 @@ "services": { "request_data": { "name": "Request data", - "description": "Requesta new data from the charging station." + "description": "Requests new data from the charging station." }, "authorize": { "name": "Authorize", @@ -46,7 +46,7 @@ "fields": { "failsafe_timeout": { "name": "Failsafe timeout", - "description": "Timeout after which the failsafe mode is triggered, if set_current was not executed during this time." + "description": "Timeout after which the failsafe mode is triggered if the 'Set current' action was not run during this time." }, "failsafe_fallback": { "name": "Failsafe fallback", @@ -54,7 +54,7 @@ }, "failsafe_persist": { "name": "Failsafe persist", - "description": "If failsafe_persist is 0, the failsafe option is only until charging station reboot. If failsafe_persist is 1, the failsafe option will survive a reboot." + "description": "If set to 0, the failsafe option will be disabled after a charging station reboot. If set to 1, the failsafe option will survive a reboot." } } } diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 86b2f61a872..38e64274deb 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "iot_class": "cloud_polling", "loggers": ["lacrosse_view"], - "requirements": ["lacrosse-view==1.0.4"] + "requirements": ["lacrosse-view==1.1.1"] } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index b2ad9672504..fceddeb9b2c 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -45,7 +45,7 @@ class LaCrosseSensorEntityDescription(SensorEntityDescription): def get_value(sensor: Sensor, field: str) -> float | int | str | None: """Get the value of a sensor field.""" - field_data = sensor.data.get(field) + field_data = sensor.data.get(field) if sensor.data is not None else None if field_data is None: return None value = field_data["values"][-1]["s"] @@ -178,7 +178,7 @@ async def async_setup_entry( continue # if the API returns a different unit of measurement from the description, update it - if sensor.data.get(field) is not None: + if sensor.data is not None and sensor.data.get(field) is not None: native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( sensor.data[field].get("unit") ) @@ -240,7 +240,9 @@ class LaCrosseViewSensor( @property def available(self) -> bool: """Return True if entity is available.""" + data = self.coordinator.data[self.index].data return ( super().available - and self.entity_description.key in self.coordinator.data[self.index].data + and data is not None + and self.entity_description.key in data ) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 87cf04e0c1a..42ae5746f24 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -7,8 +7,11 @@ "express_mode": { "default": "mdi:snowflake-variant" }, + "express_fridge": { + "default": "mdi:snowflake" + }, "hot_water_mode": { - "default": "mdi:list-status" + "default": "mdi:heat-wave" }, "humidity_warm_mode": { "default": "mdi:heat-wave" @@ -39,6 +42,9 @@ }, "warm_mode": { "default": "mdi:heat-wave" + }, + "display_light": { + "default": "mdi:lightbulb-on-outline" } }, "binary_sensor": { diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 6dd60909c66..b00d28c1d4f 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.2"] + "requirements": ["thinqconnect==1.0.4"] } diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index a776dde2054..8f498e0f8a2 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -30,10 +30,13 @@ "name": "Auto mode" }, "express_mode": { - "name": "Ice plus" + "name": "Express mode" + }, + "express_fridge": { + "name": "Express cool" }, "hot_water_mode": { - "name": "Hot water" + "name": "Heating water" }, "humidity_warm_mode": { "name": "Warm mist" @@ -64,6 +67,9 @@ }, "warm_mode": { "name": "Heating" + }, + "display_light": { + "name": "Lighting" } }, "binary_sensor": { diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py index 25fd7eb8b64..6d69ce9a314 100644 --- a/homeassistant/components/lg_thinq/switch.py +++ b/homeassistant/components/lg_thinq/switch.py @@ -33,6 +33,18 @@ class ThinQSwitchEntityDescription(SwitchEntityDescription): DEVICE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[ThinQSwitchEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( + ThinQSwitchEntityDescription( + key=ThinQProperty.AIR_CON_OPERATION_MODE, + translation_key="operation_power", + entity_category=EntityCategory.CONFIG, + ), + ThinQSwitchEntityDescription( + key=ThinQProperty.DISPLAY_LIGHT, + translation_key=ThinQProperty.DISPLAY_LIGHT, + on_key="on", + off_key="off", + entity_category=EntityCategory.CONFIG, + ), ThinQSwitchEntityDescription( key=ThinQProperty.POWER_SAVE_ENABLED, translation_key=ThinQProperty.POWER_SAVE_ENABLED, @@ -121,8 +133,20 @@ DEVICE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[ThinQSwitchEntityDescription, ... off_key="false", entity_category=EntityCategory.CONFIG, ), + ThinQSwitchEntityDescription( + key=ThinQProperty.EXPRESS_FRIDGE, + translation_key=ThinQProperty.EXPRESS_FRIDGE, + on_key="true", + off_key="false", + entity_category=EntityCategory.CONFIG, + ), ), DeviceType.SYSTEM_BOILER: ( + ThinQSwitchEntityDescription( + key=ThinQProperty.BOILER_OPERATION_MODE, + translation_key="operation_power", + entity_category=EntityCategory.CONFIG, + ), ThinQSwitchEntityDescription( key=ThinQProperty.HOT_WATER_MODE, translation_key=ThinQProperty.HOT_WATER_MODE, diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d87dcf41161..637ba45c7d9 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -8,7 +8,7 @@ import dataclasses from functools import partial import logging import os -from typing import Any, Final, Self, cast, final +from typing import TYPE_CHECKING, Any, Final, Self, cast, final from propcache.api import cached_property import voluptuous as vol @@ -528,6 +528,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: rgb_color = params.pop(ATTR_RGB_COLOR) assert rgb_color is not None + if TYPE_CHECKING: + rgb_color = cast(tuple[int, int, int], rgb_color) if ColorMode.RGBW in supported_color_modes: params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color) elif ColorMode.RGBWW in supported_color_modes: @@ -601,6 +603,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ): rgbww_color = params.pop(ATTR_RGBWW_COLOR) assert rgbww_color is not None + if TYPE_CHECKING: + rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color) rgb_color = color_util.color_rgbww_to_rgb( *rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py index 5d987a24b2a..e4aa30c98df 100644 --- a/homeassistant/components/linear_garage_door/__init__.py +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -2,9 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from .const import DOMAIN from .coordinator import LinearUpdateCoordinator @@ -15,6 +16,21 @@ PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Linear Garage Door from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + breaks_in_ha_version="2025.8.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_integration", + translation_placeholders={ + "nice_go": "https://www.home-assistant.io/integrations/linear_garage_door", + "entries": "/config/integrations/integration/linear_garage_door", + }, + ) + coordinator = LinearUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() @@ -27,6 +43,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py index 35ccced3274..38b1306ec38 100644 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any +from typing import Any, cast from linear_garage_door import Linear from linear_garage_door.errors import InvalidLoginError @@ -56,7 +56,7 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): for device in self._devices: device_id = str(device["id"]) state = await linear.get_device_state(device_id) - data[device_id] = LinearDevice(device["name"], state) + data[device_id] = LinearDevice(cast(str, device["name"]), state) return data return await self.execute(update_data) diff --git a/homeassistant/components/linear_garage_door/strings.json b/homeassistant/components/linear_garage_door/strings.json index 23624b4acfd..40ffcf22e8d 100644 --- a/homeassistant/components/linear_garage_door/strings.json +++ b/homeassistant/components/linear_garage_door/strings.json @@ -23,5 +23,11 @@ "name": "[%key:component::light::title%]" } } + }, + "issues": { + "deprecated_integration": { + "title": "The Linear Garage Door integration will be removed", + "description": "The Linear Garage Door integration will be removed as it has been replaced by the [Nice G.O.]({nice_go}) integration. Please migrate to the new integration.\n\nTo resolve this issue, please remove all Linear Garage Door entries from your configuration and add the new Nice G.O. integration. [Click here to see your existing Linear Garage Door integration entries]({entries})." + } } } diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 79b792935a8..0b432f88045 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -277,20 +277,6 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( } ) -PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = { - "button_0": 2, - "button_2": 4, -} -PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = { - "button_0": 0, - "button_2": 2, -} -PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP), - } -) - DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, @@ -302,7 +288,6 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -315,7 +300,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP, } DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { @@ -328,7 +312,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = { @@ -343,7 +326,6 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, ) diff --git a/homeassistant/components/madvr/__init__.py b/homeassistant/components/madvr/__init__.py index bb42adb21fc..cf681bd0b65 100644 --- a/homeassistant/components/madvr/__init__.py +++ b/homeassistant/components/madvr/__init__.py @@ -6,17 +6,13 @@ import logging from madvr.madvr import Madvr -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.REMOTE, Platform.SENSOR] - -type MadVRConfigEntry = ConfigEntry[MadVRCoordinator] - _LOGGER = logging.getLogger(__name__) @@ -41,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> boo connect_timeout=10, loop=hass.loop, ) - coordinator = MadVRCoordinator(hass, madVRClient) + coordinator = MadVRCoordinator(hass, entry, madVRClient) entry.runtime_data = coordinator diff --git a/homeassistant/components/madvr/binary_sensor.py b/homeassistant/components/madvr/binary_sensor.py index 6a31f9cdcda..b6820f94fea 100644 --- a/homeassistant/components/madvr/binary_sensor.py +++ b/homeassistant/components/madvr/binary_sensor.py @@ -12,8 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MadVRConfigEntry -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity _HDR_FLAG = "hdr_flag" diff --git a/homeassistant/components/madvr/coordinator.py b/homeassistant/components/madvr/coordinator.py index 4031ba127f7..c1ed87fbee7 100644 --- a/homeassistant/components/madvr/coordinator.py +++ b/homeassistant/components/madvr/coordinator.py @@ -3,10 +3,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any from madvr.madvr import Madvr +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,8 +15,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from . import MadVRConfigEntry +type MadVRConfigEntry = ConfigEntry[MadVRCoordinator] class MadVRCoordinator(DataUpdateCoordinator[dict[str, Any]]): @@ -26,10 +26,11 @@ class MadVRCoordinator(DataUpdateCoordinator[dict[str, Any]]): def __init__( self, hass: HomeAssistant, + config_entry: MadVRConfigEntry, client: Madvr, ) -> None: """Initialize madvr coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN) + super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) assert self.config_entry.unique_id self.mac = self.config_entry.unique_id self.client = client diff --git a/homeassistant/components/madvr/diagnostics.py b/homeassistant/components/madvr/diagnostics.py index f6261d27305..39e17a13d6f 100644 --- a/homeassistant/components/madvr/diagnostics.py +++ b/homeassistant/components/madvr/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import MadVRConfigEntry +from .coordinator import MadVRConfigEntry TO_REDACT = [CONF_HOST] diff --git a/homeassistant/components/madvr/remote.py b/homeassistant/components/madvr/remote.py index 4fe02b7ae47..032a1d718f5 100644 --- a/homeassistant/components/madvr/remote.py +++ b/homeassistant/components/madvr/remote.py @@ -10,8 +10,7 @@ from homeassistant.components.remote import RemoteEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MadVRConfigEntry -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/madvr/sensor.py b/homeassistant/components/madvr/sensor.py index 047b8bb83e6..e54e9dca476 100644 --- a/homeassistant/components/madvr/sensor.py +++ b/homeassistant/components/madvr/sensor.py @@ -16,7 +16,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MadVRConfigEntry from .const import ( ASPECT_DEC, ASPECT_INT, @@ -45,7 +44,7 @@ from .const import ( TEMP_HDMI, TEMP_MAINBOARD, ) -from .coordinator import MadVRCoordinator +from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 6882078a712..484ed94fb90 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.Objects import uint @@ -55,6 +56,8 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = None elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) + if TYPE_CHECKING: + value = cast(bool | None, value) self._attr_is_on = value diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index dd4f8314bef..b2d1c7f8ddb 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -308,7 +308,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SELECT, entity_description=MatterSelectEntityDescription( key="MatterDeviceEnergyManagementMode", - translation_key="mode", + translation_key="device_energy_management_mode", ), entity_class=MatterModeSelectEntity, required_attributes=( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f1a123c61be..f299b5cb628 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -183,6 +183,9 @@ "mode": { "name": "Mode" }, + "device_energy_management_mode": { + "name": "Energy management mode" + }, "sensitivity_level": { "name": "Sensitivity", "state": { diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py index f4292744815..5c29b29153e 100644 --- a/homeassistant/components/mcp_server/llm_api.py +++ b/homeassistant/components/mcp_server/llm_api.py @@ -35,13 +35,13 @@ class StatelessAssistAPI(llm.AssistAPI): """Return the prompt for the exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) entities = [ {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities.values() + for entity_info in exposed_entities["entities"].values() ] prompt.append(yaml_util.dump(list(entities))) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 0df2fe9335e..3cd9247c63a 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -105,10 +105,8 @@ class MillHeater(MillBaseEntity, ClimateEntity): self, coordinator: MillDataUpdateCoordinator, device: mill.Heater ) -> None: """Initialize the thermostat.""" - - super().__init__(coordinator, device) self._attr_unique_id = device.device_id - self._update_attr(device) + super().__init__(coordinator, device) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/mill/entity.py b/homeassistant/components/mill/entity.py index f24dbeb2c26..06056aba336 100644 --- a/homeassistant/components/mill/entity.py +++ b/homeassistant/components/mill/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import abstractmethod -from mill import Heater, MillDevice +from mill import MillDevice from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -45,7 +45,7 @@ class MillBaseEntity(CoordinatorEntity[MillDataUpdateCoordinator]): @abstractmethod @callback - def _update_attr(self, device: MillDevice | Heater) -> None: + def _update_attr(self, device: MillDevice) -> None: """Update the attribute of the entity.""" @property diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py index af27159caf0..b4ef7bdd2c2 100644 --- a/homeassistant/components/mill/number.py +++ b/homeassistant/components/mill/number.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mill import MillDevice +from mill import Heater, MillDevice from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.config_entries import ConfigEntry @@ -27,6 +27,7 @@ async def async_setup_entry( async_add_entities( MillNumber(mill_data_coordinator, mill_device) for mill_device in mill_data_coordinator.data.values() + if isinstance(mill_device, Heater) ) @@ -45,9 +46,8 @@ class MillNumber(MillBaseEntity, NumberEntity): mill_device: MillDevice, ) -> None: """Initialize the number.""" - super().__init__(coordinator, mill_device) self._attr_unique_id = f"{mill_device.device_id}_max_heating_power" - self._update_attr(mill_device) + super().__init__(coordinator, mill_device) @callback def _update_attr(self, device: MillDevice) -> None: diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 018b9466deb..57eead9be18 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -192,9 +192,9 @@ class MillSensor(MillBaseEntity, SensorEntity): mill_device: mill.Socket | mill.Heater, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, mill_device) self.entity_description = entity_description self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}" + super().__init__(coordinator, mill_device) @callback def _update_attr(self, device): diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 8e55fad4a8b..685c3ebf932 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -1,6 +1,9 @@ """Support for MotionMount sensors.""" +from typing import Final + import motionmount +from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant @@ -9,6 +12,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MotionMountConfigEntry from .entity import MotionMountEntity +ERROR_MESSAGES: Final = { + MotionMountSystemError.MotorError: "motor", + MotionMountSystemError.ObstructionDetected: "obstruction", + MotionMountSystemError.TVWidthConstraintError: "tv_width_constraint", + MotionMountSystemError.HDMICECError: "hdmi_cec", + MotionMountSystemError.InternalError: "internal", +} + async def async_setup_entry( hass: HomeAssistant, @@ -25,7 +36,14 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): """The error status sensor of a MotionMount.""" _attr_device_class = SensorDeviceClass.ENUM - _attr_options = ["none", "motor", "internal"] + _attr_options = [ + "none", + "motor", + "hdmi_cec", + "obstruction", + "tv_width_constraint", + "internal", + ] _attr_translation_key = "motionmount_error_status" def __init__( @@ -38,13 +56,10 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): @property def native_value(self) -> str: """Return error status.""" - errors = self.mm.error_status or 0 + status = self.mm.system_status - if errors & (1 << 31): - # Only when but 31 is set are there any errors active at this moment - if errors & (1 << 10): - return "motor" - - return "internal" + for error, message in ERROR_MESSAGES.items(): + if error in status: + return message return "none" diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 1fcb6c47c99..75fd0773322 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -72,6 +72,9 @@ "state": { "none": "None", "motor": "Motor", + "hdmi_cec": "HDMI CEC", + "obstruction": "Obstruction", + "tv_width_constraint": "TV width constraint", "internal": "Internal" } } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 8b16e9fa53d..6656afe2c8a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -236,7 +236,7 @@ CONFIG_SCHEMA = vol.Schema( MQTT_PUBLISH_SCHEMA = vol.Schema( { vol.Required(ATTR_TOPIC): valid_publish_topic, - vol.Required(ATTR_PAYLOAD): cv.string, + vol.Required(ATTR_PAYLOAD, default=None): vol.Any(cv.string, None), vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean, vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index c5e4f372bd6..f6fac1d2c1e 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -8,7 +8,6 @@ publish: selector: text: payload: - required: true example: "The temperature is {{ states('sensor.temperature') }}" selector: template: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index bf0bd594ea4..fc316306d56 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -238,11 +238,7 @@ }, "payload": { "name": "Payload", - "description": "The payload to publish." - }, - "payload_template": { - "name": "Payload template", - "description": "Template to render as a payload value. If a payload is provided, the template is ignored." + "description": "The payload to publish. Publishes an empty message if not provided." }, "qos": { "name": "QoS", diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index cd961276082..a0d8bc06640 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.1"] + "requirements": ["google-nest-sdm==7.1.3"] } diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 463fcc919c7..bdde3a4567e 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -494,7 +494,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { SIGNAL_STRENGTH_DECIBELS_MILLIWATT, }, NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure), - NumberDeviceClass.SPEED: set(UnitOfSpeed).union(set(UnitOfVolumetricFlux)), + NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux}, NumberDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature), NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { diff --git a/homeassistant/components/omnilogic/coordinator.py b/homeassistant/components/omnilogic/coordinator.py index 72d16f03328..24c8cdf2554 100644 --- a/homeassistant/components/omnilogic/coordinator.py +++ b/homeassistant/components/omnilogic/coordinator.py @@ -18,6 +18,8 @@ _LOGGER = logging.getLogger(__name__) class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): """Class to manage fetching update data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -28,11 +30,11 @@ class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any ) -> None: """Initialize the global Omnilogic data updater.""" self.api = api - self.config_entry = config_entry super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name=name, update_interval=timedelta(seconds=polling_interval), ) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index ef7ddd04da6..5feefb2cf7d 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from typing import cast from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk.exceptions import ( @@ -13,6 +15,7 @@ from onedrive_personal_sdk.exceptions import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,7 +25,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id -from .api import OneDriveConfigEntryAccessTokenProvider from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @@ -31,7 +33,7 @@ class OneDriveRuntimeData: """Runtime data for the OneDrive integration.""" client: OneDriveClient - token_provider: OneDriveConfigEntryAccessTokenProvider + token_function: Callable[[], Awaitable[str]] backup_folder_id: str @@ -46,9 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> session = OAuth2Session(hass, entry, implementation) - token_provider = OneDriveConfigEntryAccessTokenProvider(session) + async def get_access_token() -> str: + await session.async_ensure_token_valid() + return cast(str, session.token[CONF_ACCESS_TOKEN]) - client = OneDriveClient(token_provider, async_get_clientsession(hass)) + client = OneDriveClient(get_access_token, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist try: @@ -81,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> entry.runtime_data = OneDriveRuntimeData( client=client, - token_provider=token_provider, + token_function=get_access_token, backup_folder_id=backup_folder.id, ) diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py deleted file mode 100644 index d8f6ea188f3..00000000000 --- a/homeassistant/components/onedrive/api.py +++ /dev/null @@ -1,34 +0,0 @@ -"""API for OneDrive bound to Home Assistant OAuth.""" - -from typing import cast - -from onedrive_personal_sdk import TokenProvider - -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers import config_entry_oauth2_flow - - -class OneDriveConfigFlowAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, token: str) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._token = token - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return self._token - - -class OneDriveConfigEntryAccessTokenProvider(TokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: - """Initialize OneDrive auth.""" - super().__init__() - self._oauth_session = oauth_session - - def async_get_access_token(self) -> str: - """Return a valid access token.""" - return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 43eac020538..78bdcb24b8c 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -109,7 +109,7 @@ class OneDriveBackupAgent(BackupAgent): self._hass = hass self._entry = entry self._client = entry.runtime_data.client - self._token_provider = entry.runtime_data.token_provider + self._token_function = entry.runtime_data.token_function self._folder_id = entry.runtime_data.backup_folder_id self.name = entry.title assert entry.unique_id @@ -145,7 +145,7 @@ class OneDriveBackupAgent(BackupAgent): ) try: item = await LargeFileUploadClient.upload( - self._token_provider, file, session=async_get_clientsession(self._hass) + self._token_function, file, session=async_get_clientsession(self._hass) ) except HashMismatchError as err: raise BackupAgentError( diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index cbdf59648b9..900db0177d9 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -12,7 +12,6 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .api import OneDriveConfigFlowAccessTokenProvider from .const import DOMAIN, OAUTH_SCOPES @@ -36,12 +35,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): data: dict[str, Any], ) -> ConfigFlowResult: """Handle the initial step.""" - token_provider = OneDriveConfigFlowAccessTokenProvider( - cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - ) + + async def get_access_token() -> str: + return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) graph_client = OneDriveClient( - token_provider, async_get_clientsession(self.hass) + get_access_token, async_get_clientsession(self.hass) ) try: diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 47eb48742be..88d51e6d73a 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.4"] + "requirements": ["onedrive-personal-sdk==0.0.8"] } diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 5c33ca3fcec..2aa0879ffed 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -27,7 +27,7 @@ REGISTERED_NOTIFICATIONS = ( JSON_PAYLOAD = ( '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' - '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_id\\":\\"{{media_tmdbid}}\\",\\"t' 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json index 396b9d7000b..6258481adcf 100644 --- a/homeassistant/components/overseerr/manifest.json +++ b/homeassistant/components/overseerr/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["python-overseerr==0.6.0"] + "requirements": ["python-overseerr==0.7.0"] } diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index b3979580990..de686cad37d 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -21,6 +21,8 @@ from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT class PicnicUpdateCoordinator(DataUpdateCoordinator): """The coordinator to fetch data from the Picnic API at a set interval.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -29,13 +31,13 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): ) -> None: """Initialize the coordinator with the given Picnic API client.""" self.picnic_api_client = picnic_api_client - self.config_entry = config_entry self._user_address = None logger = logging.getLogger(__name__) super().__init__( hass, logger, + config_entry=config_entry, name="Picnic coordinator", update_interval=timedelta(minutes=30), ) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index a94000934eb..bf33d4c4a0f 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -59,8 +59,6 @@ def smile_user_schema(discovery_info: ZeroconfServiceInfo | None) -> vol.Schema: schema = schema.extend( { vol.Required(CONF_HOST): str, - # Port under investigation for removal (hence not added in #132878) - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Required(CONF_USERNAME, default=SMILE): vol.In( {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} ), @@ -197,6 +195,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: + user_input[CONF_PORT] = DEFAULT_PORT if self.discovery_info: user_input[CONF_HOST] = self.discovery_info.host user_input[CONF_PORT] = self.discovery_info.port diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index a564d33e777..aad61458e88 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -5,7 +5,7 @@ "user": { "title": "Fill in your information", "data": { - "ip_address": "Hostname or IP Address", + "ip_address": "Hostname or IP address", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } @@ -157,7 +157,7 @@ }, "unpause_watering": { "name": "Unpause all watering", - "description": "Unpauses all paused watering activities.", + "description": "Resumes all paused watering activities.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -167,7 +167,7 @@ }, "push_flow_meter_data": { "name": "Push flow meter data", - "description": "Push flow meter data to the RainMachine device.", + "description": "Sends flow meter data from Home Assistant to the RainMachine device.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -185,7 +185,7 @@ }, "push_weather_data": { "name": "Push weather data", - "description": "Push weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API Here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", + "description": "Sends weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", @@ -193,7 +193,7 @@ }, "timestamp": { "name": "Timestamp", - "description": "UNIX Timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." + "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." }, "mintemp": { "name": "Min temp", @@ -251,7 +251,7 @@ }, "unrestrict_watering": { "name": "Unrestrict all watering", - "description": "Unrestrict all watering activities.", + "description": "Removes all watering restrictions.", "fields": { "device_id": { "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index aed2fcf8508..8958913bce6 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -766,7 +766,7 @@ def _sorted_states_to_dict( attr_cache, start_time_ts, entity_id, - prev_state, # type: ignore[arg-type] + prev_state, first_state[last_updated_ts_idx], no_attributes, ) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 83729fef3cd..505358a07f7 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.8"] + "requirements": ["reolink-aio==0.11.10"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e4b52c85d45..d8fabfaa3b8 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -424,6 +424,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_brightness", cmd_key="GetImage", + cmd_id=26, translation_key="image_brightness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -437,6 +438,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_contrast", cmd_key="GetImage", + cmd_id=26, translation_key="image_contrast", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -450,6 +452,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_saturation", cmd_key="GetImage", + cmd_id=26, translation_key="image_saturation", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -463,6 +466,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_sharpness", cmd_key="GetImage", + cmd_id=26, translation_key="image_sharpness", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -476,6 +480,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="image_hue", cmd_key="GetImage", + cmd_id=26, translation_key="image_hue", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7a74be2e28c..df8c0269957 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -80,6 +80,7 @@ SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="day_night_mode", cmd_key="GetIsp", + cmd_id=26, translation_key="day_night_mode", entity_category=EntityCategory.CONFIG, get_options=[mode.name for mode in DayNightEnum], diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 7005344614c..8968ac020a2 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -23,8 +23,8 @@ "invalid_email": "There is no account associated with the email you entered, please try again.", "invalid_email_format": "There is an issue with the formatting of your email - please try again.", "too_frequent_code_requests": "You have attempted to request too many codes. Try again later.", - "unknown_roborock": "There was an unknown roborock exception - please check your logs.", - "unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.", + "unknown_roborock": "There was an unknown Roborock exception - please check your logs.", + "unknown_url": "There was an issue determining the correct URL for your Roborock account - please check your logs.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -436,11 +436,11 @@ "services": { "get_maps": { "name": "Get maps", - "description": "Get the map and room information of your device." + "description": "Retrieves the map and room information of your device." }, "set_vacuum_goto_position": { "name": "Go to position", - "description": "Send the vacuum to a specific position.", + "description": "Sends the vacuum to a specific position.", "fields": { "x": { "name": "X-coordinate", @@ -454,7 +454,7 @@ }, "get_vacuum_current_position": { "name": "Get current position", - "description": "Get the current position of the vacuum." + "description": "Retrieves the current position of the vacuum." } } } diff --git a/homeassistant/components/rympro/__init__.py b/homeassistant/components/rympro/__init__.py index f24735f4ed0..20d208cca69 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data={**data, CONF_TOKEN: token}, ) - coordinator = RymProDataUpdateCoordinator(hass, rympro) + coordinator = RymProDataUpdateCoordinator(hass, entry, rympro) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index 19f16005578..55e5f0f90df 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -7,6 +7,7 @@ import logging from pyrympro import CannotConnectError, OperationError, RymPro, UnauthorizedError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,13 +21,18 @@ _LOGGER = logging.getLogger(__name__) class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): """Class to manage fetching RYM Pro data.""" - def __init__(self, hass: HomeAssistant, rympro: RymPro) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, rympro: RymPro + ) -> None: """Initialize global RymPro data updater.""" self.rympro = rympro interval = timedelta(seconds=SCAN_INTERVAL) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, ) @@ -40,7 +46,6 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): meter_id ) except UnauthorizedError as error: - assert self.config_entry await self.hass.config_entries.async_reload(self.config_entry.entry_id) raise UpdateFailed(error) from error except (CannotConnectError, OperationError) as error: diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index a90c9cb2cf4..b3c438dc641 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -52,6 +52,8 @@ async def async_get_connect_info( class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage the data update for the Screenlogic component.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -60,7 +62,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): gateway: ScreenLogicGateway, ) -> None: """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry self.gateway = gateway interval = timedelta( @@ -69,6 +70,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, # Debounced option since the device takes @@ -91,7 +93,6 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None try: if not self.gateway.is_connected: connect_info = await async_get_connect_info( diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 59a87c419e0..c46aca548c8 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -590,7 +590,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SIGNAL_STRENGTH_DECIBELS_MILLIWATT, }, SensorDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure), - SensorDeviceClass.SPEED: set(UnitOfSpeed).union(set(UnitOfVolumetricFlux)), + SensorDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux}, SensorDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.TEMPERATURE: set(UnitOfTemperature), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index e0d8c03ffc4..4cfb49b680f 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.3.2"], + "requirements": ["aioshelly==12.4.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 2521df3a333..387edfc6e11 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from smhi.smhi_lib import Smhi, SmhiForecastException +from pysmhi import SmhiForecastException, SMHIPointForecast import voluptuous as vol from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -26,9 +26,9 @@ async def async_check_location( ) -> bool: """Return true if location is ok.""" session = aiohttp_client.async_get_clientsession(hass) - smhi_api = Smhi(longitude, latitude, session=session) + smhi_api = SMHIPointForecast(str(longitude), str(latitude), session=session) try: - await smhi_api.async_get_forecast() + await smhi_api.async_get_daily_forecast() except SmhiForecastException: return False diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 645ace41cab..fc3af634764 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", - "loggers": ["smhi"], - "requirements": ["smhi-pkg==1.0.19"] + "loggers": ["pysmhi"], + "requirements": ["pysmhi==1.0.0"] } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index d43ca4465ae..1707afa2fca 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -9,8 +9,7 @@ import logging from typing import Any, Final import aiohttp -from smhi import Smhi -from smhi.smhi_lib import SmhiForecast, SmhiForecastException +from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -59,7 +58,7 @@ from homeassistant.helpers import aiohttp_client, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.util import Throttle from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT @@ -139,10 +138,10 @@ class SmhiWeather(WeatherEntity): ) -> None: """Initialize the SMHI weather entity.""" self._attr_unique_id = f"{latitude}, {longitude}" - self._forecast_daily: list[SmhiForecast] | None = None - self._forecast_hourly: list[SmhiForecast] | None = None + self._forecast_daily: list[SMHIForecast] | None = None + self._forecast_hourly: list[SMHIForecast] | None = None self._fail_count = 0 - self._smhi_api = Smhi(longitude, latitude, session=session) + self._smhi_api = SMHIPointForecast(longitude, latitude, session=session) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{latitude}, {longitude}")}, @@ -156,7 +155,7 @@ class SmhiWeather(WeatherEntity): """Return additional attributes.""" if self._forecast_daily: return { - ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0].thunder, + ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0]["thunder"], } return None @@ -165,8 +164,8 @@ class SmhiWeather(WeatherEntity): """Refresh the forecast data from SMHI weather API.""" try: async with asyncio.timeout(TIMEOUT): - self._forecast_daily = await self._smhi_api.async_get_forecast() - self._forecast_hourly = await self._smhi_api.async_get_forecast_hour() + self._forecast_daily = await self._smhi_api.async_get_daily_forecast() + self._forecast_hourly = await self._smhi_api.async_get_hourly_forecast() self._fail_count = 0 except (TimeoutError, SmhiForecastException): _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") @@ -176,15 +175,15 @@ class SmhiWeather(WeatherEntity): return if self._forecast_daily: - self._attr_native_temperature = self._forecast_daily[0].temperature - self._attr_humidity = self._forecast_daily[0].humidity - self._attr_native_wind_speed = self._forecast_daily[0].wind_speed - self._attr_wind_bearing = self._forecast_daily[0].wind_direction - self._attr_native_visibility = self._forecast_daily[0].horizontal_visibility - self._attr_native_pressure = self._forecast_daily[0].pressure - self._attr_native_wind_gust_speed = self._forecast_daily[0].wind_gust - self._attr_cloud_coverage = self._forecast_daily[0].cloudiness - self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0].symbol) + self._attr_native_temperature = self._forecast_daily[0]["temperature"] + self._attr_humidity = self._forecast_daily[0]["humidity"] + self._attr_native_wind_speed = self._forecast_daily[0]["wind_speed"] + self._attr_wind_bearing = self._forecast_daily[0]["wind_direction"] + self._attr_native_visibility = self._forecast_daily[0]["visibility"] + self._attr_native_pressure = self._forecast_daily[0]["pressure"] + self._attr_native_wind_gust_speed = self._forecast_daily[0]["wind_gust"] + self._attr_cloud_coverage = self._forecast_daily[0]["total_cloud"] + self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0]["symbol"]) if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up( self.hass ): @@ -196,7 +195,7 @@ class SmhiWeather(WeatherEntity): await self.async_update(no_throttle=True) def _get_forecast_data( - self, forecast_data: list[SmhiForecast] | None + self, forecast_data: list[SMHIForecast] | None ) -> list[Forecast] | None: """Get forecast data.""" if forecast_data is None or len(forecast_data) < 3: @@ -205,25 +204,28 @@ class SmhiWeather(WeatherEntity): data: list[Forecast] = [] for forecast in forecast_data[1:]: - condition = CONDITION_MAP.get(forecast.symbol) + condition = CONDITION_MAP.get(forecast["symbol"]) if condition == ATTR_CONDITION_SUNNY and not sun.is_up( - self.hass, forecast.valid_time.replace(tzinfo=dt_util.UTC) + self.hass, forecast["valid_time"] ): condition = ATTR_CONDITION_CLEAR_NIGHT data.append( { - ATTR_FORECAST_TIME: forecast.valid_time.isoformat(), - ATTR_FORECAST_NATIVE_TEMP: forecast.temperature_max, - ATTR_FORECAST_NATIVE_TEMP_LOW: forecast.temperature_min, - ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.total_precipitation, + ATTR_FORECAST_TIME: forecast["valid_time"].isoformat(), + ATTR_FORECAST_NATIVE_TEMP: forecast["temperature_max"], + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast["temperature_min"], + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.get( + "total_precipitation" + ) + or forecast["mean_precipitation"], ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_NATIVE_PRESSURE: forecast.pressure, - ATTR_FORECAST_WIND_BEARING: forecast.wind_direction, - ATTR_FORECAST_NATIVE_WIND_SPEED: forecast.wind_speed, - ATTR_FORECAST_HUMIDITY: forecast.humidity, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast.wind_gust, - ATTR_FORECAST_CLOUD_COVERAGE: forecast.cloudiness, + ATTR_FORECAST_NATIVE_PRESSURE: forecast["pressure"], + ATTR_FORECAST_WIND_BEARING: forecast["wind_direction"], + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast["wind_speed"], + ATTR_FORECAST_HUMIDITY: forecast["humidity"], + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast["wind_gust"], + ATTR_FORECAST_CLOUD_COVERAGE: forecast["total_cloud"], } ) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index cec5d6a6d8b..3f527d1fcd9 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.2.2"], + "requirements": ["pysmlight==0.2.3"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index cd36ccf7731..ac861e72b72 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -5,7 +5,7 @@ }, "error": { "db_url_invalid": "Database URL invalid", - "query_invalid": "SQL Query invalid", + "query_invalid": "SQL query invalid", "query_no_read_only": "SQL query must be read-only", "multiple_queries": "Multiple SQL queries are not supported", "column_invalid": "The column `{column}` is not returned by the query" @@ -15,22 +15,22 @@ "data": { "db_url": "Database URL", "name": "[%key:common::config_flow::data::name%]", - "query": "Select Query", + "query": "Select query", "column": "Column", - "unit_of_measurement": "Unit of Measure", - "value_template": "Value Template", - "device_class": "Device Class", - "state_class": "State Class" + "unit_of_measurement": "Unit of measurement", + "value_template": "Value template", + "device_class": "Device class", + "state_class": "State class" }, "data_description": { - "db_url": "Database URL, leave empty to use HA recorder database", - "name": "Name that will be used for Config Entry and also the Sensor", + "db_url": "Leave empty to use Home Assistant Recorder database", + "name": "Name that will be used for config entry and also the sensor", "query": "Query to run, needs to start with 'SELECT'", "column": "Column for returned query to present as state", - "unit_of_measurement": "Unit of Measure (optional)", - "value_template": "Value Template (optional)", + "unit_of_measurement": "The unit of measurement for the sensor (optional)", + "value_template": "Template to extract a value from the payload (optional)", "device_class": "The type/class of the sensor to set the icon in the frontend", - "state_class": "The state_class of the sensor" + "state_class": "The state class of the sensor" } } } diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index f79c2aaa99c..ab81c8b5a53 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -16,5 +16,5 @@ "documentation": "https://www.home-assistant.io/integrations/steamist", "iot_class": "local_polling", "loggers": ["aiosteamist", "discovery30303"], - "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.2"] + "requirements": ["aiosteamist==1.0.1", "discovery30303==0.3.3"] } diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 499a5073872..09bc157d4d2 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -65,6 +65,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1PM.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], + SupportedModels.REMOTE.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 854ab32b657..16b41d75541 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -34,6 +34,7 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1PM = "relay_switch_1pm" RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" + REMOTE = "remote" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -60,6 +61,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, + SwitchbotModel.REMOTE: SupportedModels.REMOTE, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 1b80da43e16..92a1c25d6f5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.55.4"] + "requirements": ["PySwitchbot==0.56.0"] } diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 7d2224fc6fc..37e9ee3d929 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -220,7 +220,7 @@ async def handle_info( # Update subscription of all finished tasks for result in done: domain, key = pending_lookup[result] - event_msg = { + event_msg: dict[str, Any] = { "type": "update", "domain": domain, "key": key, diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f744265e1c2..fa3ec1dc4f7 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -175,6 +175,7 @@ BASE_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, vol.Optional(ATTR_TIMEOUT): cv.positive_int, vol.Optional(ATTR_MESSAGE_TAG): cv.string, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), }, extra=vol.ALLOW_EXTRA, ) @@ -216,6 +217,7 @@ SERVICE_SCHEMA_SEND_POLL = vol.Schema( vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, vol.Optional(ATTR_TIMEOUT): cv.positive_int, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), } ) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index fa0f336eb18..330745316d7 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6"] + "requirements": ["tesla-fleet-api==0.9.8"] } diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 3e05e7e723b..c1d38bf85c5 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -303,8 +303,8 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslaFleetTimeEntityDescription, ...] = ( ), ) -ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ENERGY_LIVE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = ( + TeslaFleetSensorEntityDescription( key="solar_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -312,7 +312,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="energy_left", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -321,7 +321,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="total_pack_energy", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -331,14 +331,15 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="percentage_charged", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="battery_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -346,7 +347,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="load_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -354,7 +355,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -362,7 +363,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="grid_services_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -370,7 +371,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="generator_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -379,7 +380,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslaFleetSensorEntityDescription( key="island_status", options=[ "island_status_unknown", @@ -550,12 +551,12 @@ class TeslaFleetVehicleTimeSensorEntity(TeslaFleetVehicleEntity, SensorEntity): class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity): """Base class for Tesla Fleet energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslaFleetSensorEntityDescription def __init__( self, data: TeslaFleetEnergyData, - description: SensorEntityDescription, + description: TeslaFleetSensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -563,7 +564,7 @@ class TeslaFleetEnergyLiveSensorEntity(TeslaFleetEnergyLiveEntity, SensorEntity) def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_native_value = self._value + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslaFleetEnergyHistorySensorEntity(TeslaFleetEnergyHistoryEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 749bd7c4173..bfa0d831a16 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.6", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index baf1d80ac6c..d2e90a4f5c9 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -2,18 +2,27 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from itertools import chain +from typing import Any +from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -24,53 +33,136 @@ HIGH = "high" PARALLEL_UPDATES = 0 +LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3} + @dataclass(frozen=True, kw_only=True) -class SeatHeaterDescription(SelectEntityDescription): +class TeslemetrySelectEntityDescription(SelectEntityDescription): """Seat Heater entity description.""" - position: Seat - available_fn: Callable[[TeslemetrySeatHeaterSelectEntity], bool] = lambda _: True + select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]] + supported_fn: Callable[[dict], bool] = lambda _: True + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[int | None], None]], + Callable[[], None], + ] + | None + ) = None + options: list[str] -SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( - SeatHeaterDescription( +VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_left", - position=Seat.FRONT_LEFT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_LEFT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterLeft(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_right", - position=Seat.FRONT_RIGHT, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.FRONT_RIGHT, level + ), + streaming_listener=lambda x, y: x.listen_SeatHeaterRight(y), + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_left", - position=Seat.REAR_LEFT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_LEFT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearLeft(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_center", - position=Seat.REAR_CENTER, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_CENTER, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearCenter(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_rear_right", - position=Seat.REAR_RIGHT, - available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.REAR_RIGHT, level + ), + supported_fn=lambda data: data.get("vehicle_config_rear_seat_heaters") != 0, + streaming_listener=lambda x, y: x.listen_SeatHeaterRearRight(y), entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_left", - position=Seat.THIRD_LEFT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_LEFT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], ), - SeatHeaterDescription( + TeslemetrySelectEntityDescription( key="climate_state_seat_heater_third_row_right", - position=Seat.THIRD_RIGHT, - available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + select_fn=lambda api, level: api.remote_seat_heater_request( + Seat.THIRD_RIGHT, level + ), + supported_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", entity_registry_enabled_default=False, + options=[ + OFF, + LOW, + MEDIUM, + HIGH, + ], + ), + TeslemetrySelectEntityDescription( + key="climate_state_steering_wheel_heat_level", + select_fn=lambda api, level: api.remote_steering_wheel_heat_level_request( + level + ), + streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatLevel(y), + options=[ + OFF, + LOW, + HIGH, + ], ), ) @@ -85,17 +177,18 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetrySeatHeaterSelectEntity( + TeslemetryPollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - for description in SEAT_HEATER_DESCRIPTIONS + if vehicle.api.pre2021 + or vehicle.firmware < "2024.26" + or description.streaming_listener is None + else TeslemetryStreamingSelectEntity( + vehicle, description, entry.runtime_data.scopes + ) + for description in VEHICLE_DESCRIPTIONS for vehicle in entry.runtime_data.vehicles - if description.key in vehicle.coordinator.data - ), - ( - TeslemetryWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("climate_state_steering_wheel_heater") + if description.supported_fn(vehicle.coordinator.data) ), ( TeslemetryOperationSelectEntity(energysite, entry.runtime_data.scopes) @@ -112,22 +205,31 @@ async def async_setup_entry( ) -class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle seat heater.""" +class TeslemetrySelectEntity(TeslemetryRootEntity, SelectEntity): + """Parent vehicle select entity class.""" - entity_description: SeatHeaterDescription + entity_description: TeslemetrySelectEntityDescription + _climate: bool = False - _attr_options = [ - OFF, - LOW, - MEDIUM, - HIGH, - ] + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + level = LEVEL[option] + # AC must be on to turn on heaters + if level and not self._climate: + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command(self.entity_description.select_fn(self.api, level)) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslemetryPollingSelectEntity(TeslemetryVehicleEntity, TeslemetrySelectEntity): + """Base polling vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, - description: SeatHeaterDescription, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: """Initialize the vehicle seat select entity.""" @@ -137,72 +239,63 @@ class TeslemetrySeatHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): def _async_update_attrs(self) -> None: """Handle updated data from the coordinator.""" - self._attr_available = self.entity_description.available_fn(self) - value = self._value - if not isinstance(value, int): + self._climate = bool(self.get("climate_state_is_climate_on")) + if not isinstance(self._value, int): self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on seat heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_seat_heater_request(self.entity_description.position, level) - ) - self._attr_current_option = option - self.async_write_ha_state() + self._attr_current_option = self.entity_description.options[self._value] -class TeslemetryWheelHeaterSelectEntity(TeslemetryVehicleEntity, SelectEntity): - """Select entity for vehicle steering wheel heater.""" - - _attr_options = [ - OFF, - LOW, - HIGH, - ] +class TeslemetryStreamingSelectEntity( + TeslemetryVehicleStreamEntity, TeslemetrySelectEntity, RestoreEntity +): + """Base streaming vehicle select entity class.""" def __init__( self, data: TeslemetryVehicleData, + description: TeslemetrySelectEntityDescription, scopes: list[Scope], ) -> None: - """Initialize the vehicle steering wheel select entity.""" + """Initialize the vehicle seat select entity.""" + self.entity_description = description self.scoped = Scope.VEHICLE_CMDS in scopes - super().__init__( - data, - "climate_state_steering_wheel_heat_level", + self._attr_current_option = None + super().__init__(data, description.key) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state + if (state := await self.async_get_last_state()) is not None: + if state.state in self.entity_description.options: + self._attr_current_option = state.state + + # Listen for streaming data + assert self.entity_description.streaming_listener is not None + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) ) - def _async_update_attrs(self) -> None: - """Handle updated data from the coordinator.""" + self.async_on_remove( + self.vehicle.stream_vehicle.listen_HvacACEnabled(self._climate_callback) + ) - value = self._value - if not isinstance(value, int): + def _value_callback(self, value: int | None) -> None: + """Update the value of the entity.""" + if value is None: self._attr_current_option = None else: - self._attr_current_option = self._attr_options[value] - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - level = self._attr_options.index(option) - # AC must be on to turn on steering wheel heater - if level and not self.get("climate_state_is_climate_on"): - await handle_vehicle_command(self.api.auto_conditioning_start()) - await handle_vehicle_command( - self.api.remote_steering_wheel_heat_level_request(level) - ) - self._attr_current_option = option + self._attr_current_option = self.entity_description.options[value] self.async_write_ha_state() + def _climate_callback(self, value: bool | None) -> None: + """Update the value of the entity.""" + self._climate = bool(value) + class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): """Select entity for operation mode select entities.""" diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index f6015b0ef4e..ef4d366c779 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.6"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"] } diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 7f09cef2acd..323fa76ef1f 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -258,6 +258,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), ) + ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( TessieSensorEntityDescription( key="solar_power", @@ -292,6 +293,7 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, suggested_display_precision=2, + value_fn=lambda value: value or 0, ), TessieSensorEntityDescription( key="battery_power", diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 9ca2fe80cf9..291a7e78c62 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -328,7 +328,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): host, port = self._async_get_host_port(host) - match_dict = {CONF_HOST: host} + match_dict: dict[str, Any] = {CONF_HOST: host} if port: self.port = port match_dict[CONF_PORT] = port diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index cde8c88d169..d5a7d615399 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -3,8 +3,8 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "UniFi Protect Setup", - "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect setup", + "description": "You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -17,17 +17,17 @@ } }, "reauth_confirm": { - "title": "UniFi Protect Reauth", + "title": "UniFi Protect reauth", "data": { - "host": "IP/Host of UniFi Protect Server", + "host": "IP/Host of UniFi Protect server", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } }, "discovery_confirm": { - "title": "UniFi Protect Discovered", - "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud Users will not work. For more information: {local_user_documentation_url}", + "title": "UniFi Protect discovered", + "description": "Do you want to set up {name} ({ip_address})? You will need a local user created in your UniFi OS Console to log in with. Ubiquiti Cloud users will not work. For more information: {local_user_documentation_url}", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -38,7 +38,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", - "cloud_user": "Ubiquiti Cloud users are not Supported. Please use a Local only user." + "cloud_user": "Ubiquiti Cloud users are not supported. Please use a local user instead." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -49,12 +49,12 @@ "options": { "step": { "init": { - "title": "UniFi Protect Options", + "title": "UniFi Protect options", "description": "Realtime metrics option should only be enabled if you have enabled the diagnostics sensors and want them updated in realtime. If not enabled, they will only update once every 15 minutes.", "data": { "disable_rtsp": "Disable the RTSP stream", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", - "override_connection_host": "Override Connection Host", + "override_connection_host": "Override connection host", "max_media": "Max number of event to load for Media Browser (increases RAM usage)", "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" } @@ -68,7 +68,7 @@ "step": { "start": { "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." + "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." }, "confirm": { "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", @@ -123,8 +123,8 @@ } }, "deprecate_hdr_switch": { - "title": "HDR Mode Switch Deprecated", - "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode Switch has been replaced with an HDR Mode Select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." + "title": "HDR Mode switch deprecated", + "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode switch has been replaced with an HDR Mode select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." } }, "entity": { @@ -171,22 +171,22 @@ }, "services": { "add_doorbell_text": { - "name": "Add custom doorbell text", + "name": "Add doorbell text", "description": "Adds a new custom message for doorbells.", "fields": { "device_id": { "name": "UniFi Protect NVR", - "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances." + "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect instances." }, "message": { "name": "Custom message", - "description": "New custom message to add for doorbells. Must be less than 30 characters." + "description": "New custom message to add. Must be less than 30 characters." } } }, "remove_doorbell_text": { - "name": "Remove custom doorbell text", - "description": "Removes an existing message for doorbells.", + "name": "Remove doorbell text", + "description": "Removes an existing custom message for doorbells.", "fields": { "device_id": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::name%]", @@ -194,13 +194,13 @@ }, "message": { "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::message::name%]", - "description": "Existing custom message to remove for doorbells." + "description": "Existing custom message to remove." } } }, "set_chime_paired_doorbells": { "name": "Set chime paired doorbells", - "description": "Use to set the paired doorbell(s) with a smart chime.", + "description": "Pairs doorbell(s) with a smart chime.", "fields": { "device_id": { "name": "Chime", @@ -213,22 +213,22 @@ } }, "remove_privacy_zone": { - "name": "Remove camera privacy zone", - "description": "Use to remove a privacy zone from a camera.", + "name": "Remove privacy zone", + "description": "Removes a privacy zone from a camera.", "fields": { "device_id": { "name": "Camera", - "description": "Camera you want to remove privacy zone from." + "description": "Camera you want to remove the privacy zone from." }, "name": { - "name": "Privacy Zone Name", + "name": "Privacy zone", "description": "The name of the zone to remove." } } }, "get_user_keyring_info": { - "name": "Retrieve Keyring Details for Users", - "description": "Fetch a detailed list of users with NFC and fingerprint associations for automations.", + "name": "Get user keyring info", + "description": "Fetches a detailed list of users with NFC and fingerprint associations for automations.", "fields": { "device_id": { "name": "UniFi Protect NVR", diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 1c55d932425..4951bdb2dc1 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager - coordinator = VeSyncDataCoordinator(hass, manager) + coordinator = VeSyncDataCoordinator(hass, config_entry, manager) # Store coordinator at domain level since only single integration instance is permitted. hass.data[DOMAIN][VS_COORDINATOR] = coordinator diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index e2f4e1db2e4..f817c1d0714 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -4,6 +4,8 @@ import logging from pyvesync import VeSync from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncWallSwitch from homeassistant.core import HomeAssistant @@ -54,3 +56,15 @@ def is_humidifier(device: VeSyncBaseDevice) -> bool: """Check if the device represents a humidifier.""" return isinstance(device, VeSyncHumidifierDevice) + + +def is_outlet(device: VeSyncBaseDevice) -> bool: + """Check if the device represents an outlet.""" + + return isinstance(device, VeSyncOutlet) + + +def is_wall_switch(device: VeSyncBaseDevice) -> bool: + """Check if the device represents a wall switch, note this doessn't include dimming switches.""" + + return isinstance(device, VeSyncWallSwitch) diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py index f3df2970fdb..e8c8396bfb4 100644 --- a/homeassistant/components/vesync/coordinator.py +++ b/homeassistant/components/vesync/coordinator.py @@ -7,6 +7,7 @@ import logging from pyvesync import VeSync +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -18,13 +19,18 @@ _LOGGER = logging.getLogger(__name__) class VeSyncDataCoordinator(DataUpdateCoordinator[None]): """Class representing data coordinator for VeSync devices.""" - def __init__(self, hass: HomeAssistant, manager: VeSync) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync + ) -> None: """Initialize.""" self._manager = manager super().__init__( hass, _LOGGER, + config_entry=config_entry, name="VeSyncDataCoordinator", update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index efae1192406..3d2dc8a8e96 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -1,29 +1,59 @@ """Support for VeSync switches.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from typing import Any +from typing import Any, Final from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .common import is_outlet, is_wall_switch +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class VeSyncSwitchEntityDescription(SwitchEntityDescription): + """A class that describes custom switch entities.""" + + is_on: Callable[[VeSyncBaseDevice], bool] + exists_fn: Callable[[VeSyncBaseDevice], bool] + on_fn: Callable[[VeSyncBaseDevice], bool] + off_fn: Callable[[VeSyncBaseDevice], bool] + + +SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( + VeSyncSwitchEntityDescription( + key="device_status", + is_on=lambda device: device.device_status == "on", + # Other types of wall switches support dimming. Those use light.py platform. + exists_fn=lambda device: is_wall_switch(device) or is_outlet(device), + name=None, + on_fn=lambda device: device.turn_on(), + off_fn=lambda device: device.turn_off(), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches.""" + """Set up switch platform.""" coordinator = hass.data[DOMAIN][VS_COORDINATOR] @@ -45,55 +75,46 @@ def _setup_entities( async_add_entities, coordinator: VeSyncDataCoordinator, ): - """Check if device is a switch and add entity.""" - entities: list[VeSyncBaseSwitch] = [] - for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) == "outlet": - entities.append(VeSyncSwitchHA(dev, coordinator)) - elif DEV_TYPE_TO_HA.get(dev.device_type) == "switch": - entities.append(VeSyncLightSwitch(dev, coordinator)) - - async_add_entities(entities, update_before_add=True) + """Check if device is online and add entity.""" + async_add_entities( + VeSyncSwitchEntity(dev, description, coordinator) + for dev in devices + for description in SENSOR_DESCRIPTIONS + if description.exists_fn(dev) + ) -class VeSyncBaseSwitch(VeSyncBaseEntity, SwitchEntity): - """Base class for VeSync switch Device Representations.""" +class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): + """VeSync switch entity class.""" - _attr_name = None + entity_description: VeSyncSwitchEntityDescription - def turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - self.device.turn_on() + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSwitchEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + if is_outlet(self.device): + self._attr_device_class = SwitchDeviceClass.OUTLET + elif is_wall_switch(self.device): + self._attr_device_class = SwitchDeviceClass.SWITCH @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.device.device_status == "on" + def is_on(self) -> bool | None: + """Return the entity value to represent the entity state.""" + return self.entity_description.is_on(self.device) def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self.device.turn_off() + """Turn the entity off.""" + if self.entity_description.off_fn(self.device): + self.schedule_update_ha_state() - -class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): - """Representation of a VeSync switch.""" - - def __init__( - self, plug: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize the VeSync switch device.""" - super().__init__(plug, coordinator) - self._attr_unique_id = f"{super().unique_id}-device_status" - self.smartplug = plug - - -class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): - """Handle representation of VeSync Light Switch.""" - - def __init__( - self, switch: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: - """Initialize Light Switch device class.""" - super().__init__(switch, coordinator) - self._attr_unique_id = f"{super().unique_id}-device_status" - self.switch = switch + def turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + if self.entity_description.on_fn(self.device): + self.schedule_update_ha_state() diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 10983a7ad24..c5e24f46c33 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -196,6 +196,9 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" + if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + return False + return self.percentage is not None and self.percentage > 0 def turn_off(self, **kwargs: Any) -> None: @@ -206,6 +209,8 @@ class ViCareFan(ViCareEntity, FanEntity): @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" + if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: return "mdi:fan-clock" diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py index 45261787e75..261139faf10 100644 --- a/homeassistant/components/webmin/coordinator.py +++ b/homeassistant/components/webmin/coordinator.py @@ -22,6 +22,7 @@ from .helpers import get_instance_from_options, get_sorted_mac_addresses class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The Webmin data update coordinator.""" + config_entry: ConfigEntry mac_address: str unique_id: str @@ -29,7 +30,11 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Initialize the Webmin data update coordinator.""" super().__init__( - hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self.instance, base_url = get_instance_from_options(hass, config_entry.options) @@ -53,7 +58,6 @@ class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): (DOMAIN, format_mac(mac_address)) for mac_address in mac_addresses } else: - assert self.config_entry self.unique_id = self.config_entry.entry_id async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4b9d072f747..cbb11a06aec 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.65"] + "requirements": ["holidays==0.66"] } diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index cbb092405d7..8ec7612fd73 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -6,7 +6,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The yolink integration needs to re-authenticate your account" + "description": "The YoLink integration needs to re-authenticate your account" } }, "abort": { @@ -99,11 +99,11 @@ "services": { "play_on_speaker_hub": { "name": "Play on SpeakerHub", - "description": "Convert text to audio play on YoLink SpeakerHub", + "description": "Converts text to speech for playback on a YoLink SpeakerHub", "fields": { "target_device": { - "name": "SpeakerHub Device", - "description": "SpeakerHub Device" + "name": "SpeakerHub device", + "description": "SpeakerHub device for audio playback." }, "message": { "name": "Text message", @@ -115,7 +115,7 @@ }, "volume": { "name": "Volume", - "description": "Override the speaker volume during playback of this message only." + "description": "Overrides the speaker volume during playback of this message only." }, "repeat": { "name": "Repeat", diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py index 03a27b5a378..af14d597b79 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except URLError as exception: raise ConfigEntryNotReady from exception - youless_coordinator = YouLessCoordinator(hass, api) + youless_coordinator = YouLessCoordinator(hass, entry, api) await youless_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/youless/coordinator.py b/homeassistant/components/youless/coordinator.py index 0be5e463689..81e4b3a4c76 100644 --- a/homeassistant/components/youless/coordinator.py +++ b/homeassistant/components/youless/coordinator.py @@ -5,6 +5,7 @@ import logging from youless_api import YoulessAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,10 +15,18 @@ _LOGGER = logging.getLogger(__name__) class YouLessCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching YouLess data.""" - def __init__(self, hass: HomeAssistant, device: YoulessAPI) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: YoulessAPI + ) -> None: """Initialize global YouLess data provider.""" super().__init__( - hass, _LOGGER, name="youless_gateway", update_interval=timedelta(seconds=10) + hass, + _LOGGER, + config_entry=config_entry, + name="youless_gateway", + update_interval=timedelta(seconds=10), ) self.device = device diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 28f029b62d5..e446f32cf08 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -12,6 +12,10 @@ from zha.zigbee.device import get_device_automation_triggers from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_TYPE, @@ -25,7 +29,7 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType -from . import repairs, websocket_api +from . import homeassistant_hardware, repairs, websocket_api from .const import ( CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, @@ -110,6 +114,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ha_zha_data = HAZHAData(yaml_config=config.get(DOMAIN, {})) hass.data[DATA_ZHA] = ha_zha_data + async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware) + return True @@ -218,6 +224,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_config) ) + if fw_info := homeassistant_hardware.get_firmware_info(hass, config_entry): + await async_notify_firmware_info( + hass, + DOMAIN, + firmware_info=fw_info, + ) + await ha_zha_data.gateway_proxy.async_initialize_devices_and_entities() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) diff --git a/homeassistant/components/zha/homeassistant_hardware.py b/homeassistant/components/zha/homeassistant_hardware.py new file mode 100644 index 00000000000..18057d3b64d --- /dev/null +++ b/homeassistant/components/zha/homeassistant_hardware.py @@ -0,0 +1,43 @@ +"""Home Assistant Hardware firmware utilities.""" + +from __future__ import annotations + +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .helpers import get_zha_gateway + + +@callback +def get_firmware_info( + hass: HomeAssistant, config_entry: ConfigEntry +) -> FirmwareInfo | None: + """Return firmware information for the ZHA instance, synchronously.""" + + # We only support EZSP firmware for now + if config_entry.data.get("radio_type", None) != "ezsp": + return None + + if (device := config_entry.data.get("device", {}).get("path")) is None: + return None + + try: + gateway = get_zha_gateway(hass) + except ValueError: + firmware_version = None + else: + firmware_version = gateway.state.node_info.version + + return FirmwareInfo( + device=device, + firmware_type=ApplicationType.EZSP, + firmware_version=firmware_version, + source=DOMAIN, + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + ) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6a42bc986e9..821159afb22 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.47"], + "requirements": ["zha==0.0.48"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index aaf156290a7..6a5d39bc3db 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -420,7 +420,7 @@ class ZhaMultiPANMigrationHelper: self._radio_mgr.radio_type = new_radio_type self._radio_mgr.device_path = new_device_settings[CONF_DEVICE_PATH] self._radio_mgr.device_settings = new_device_settings - device_settings = self._radio_mgr.device_settings.copy() # type: ignore[union-attr] + device_settings = self._radio_mgr.device_settings.copy() # Update the config entry settings self._hass.config_entries.async_update_entry( diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index d562a807a4f..07d897bcfd6 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -37,6 +37,7 @@ from zha.application.const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ZHA_CLUSTER_HANDLER_MSG, + ZHA_GW_MSG, ) from zha.application.gateway import Gateway from zha.application.helpers import ( @@ -330,7 +331,7 @@ async def websocket_permit_devices( connection.send_message(websocket_api.event_message(msg["id"], data)) remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages + hass, ZHA_GW_MSG, forward_messages ) @callback diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 8a5880dcde9..447b6d284f0 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -187,6 +187,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GV5126*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GV5179*", + }, { "connectable": False, "domain": "govee_ble", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index b5f5ee9a961..3d8dc247857 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -15,7 +15,7 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout -from aiohttp_asyncmdnsresolver.api import AsyncMDNSResolver +from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver from homeassistant import config_entries from homeassistant.components import zeroconf @@ -377,5 +377,5 @@ def _async_get_connector( @callback -def _async_make_resolver(hass: HomeAssistant) -> AsyncMDNSResolver: - return AsyncMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) +def _async_make_resolver(hass: HomeAssistant) -> AsyncDualMDNSResolver: + return AsyncDualMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 7300b148c77..95a32696228 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -86,9 +86,7 @@ CLEANUP_INTERVAL = 3600 * 24 ORPHANED_ENTITY_KEEP_SECONDS = 3600 * 24 * 30 ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = { - # mypy does not understand strenum - val: idx # type: ignore[misc] - for idx, val in enumerate(EntityCategory) + val: idx for idx, val in enumerate(EntityCategory) } ENTITY_CATEGORY_INDEX_TO_VALUE = dict(enumerate(EntityCategory)) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b7c4951d8de..2ef785e7f71 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -329,7 +329,7 @@ class AssistAPI(API): def _async_get_api_prompt( self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: - if not exposed_entities: + if not exposed_entities or not exposed_entities["entities"]: return ( "Only if the user wants to control a device, tell them to expose entities " "to their voice assistant in Home Assistant." @@ -392,11 +392,11 @@ class AssistAPI(API): """Return the prompt for the API for exposed entities.""" prompt = [] - if exposed_entities: + if exposed_entities and exposed_entities["entities"]: prompt.append( "An overview of the areas and the devices in this smart home:" ) - prompt.append(yaml_util.dump(list(exposed_entities.values()))) + prompt.append(yaml_util.dump(list(exposed_entities["entities"].values()))) return prompt @@ -428,8 +428,9 @@ class AssistAPI(API): exposed_domains: set[str] | None = None if exposed_entities is not None: exposed_domains = { - split_entity_id(entity_id)[0] for entity_id in exposed_entities + info["domain"] for info in exposed_entities["entities"].values() } + intent_handlers = [ intent_handler for intent_handler in intent_handlers @@ -441,25 +442,29 @@ class AssistAPI(API): IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) for intent_handler in intent_handlers ] - if exposed_domains and CALENDAR_DOMAIN in exposed_domains: - tools.append(CalendarGetEventsTool()) - if llm_context.assistant is not None: - for state in self.hass.states.async_all(SCRIPT_DOMAIN): - if not async_should_expose( - self.hass, llm_context.assistant, state.entity_id - ): - continue + if exposed_entities: + if exposed_entities[CALENDAR_DOMAIN]: + names = [] + for info in exposed_entities[CALENDAR_DOMAIN].values(): + names.extend(info["names"].split(", ")) + tools.append(CalendarGetEventsTool(names)) - tools.append(ScriptTool(self.hass, state.entity_id)) + tools.extend( + ScriptTool(self.hass, script_entity_id) + for script_entity_id in exposed_entities[SCRIPT_DOMAIN] + ) return tools def _get_exposed_entities( hass: HomeAssistant, assistant: str -) -> dict[str, dict[str, Any]]: - """Get exposed entities.""" +) -> dict[str, dict[str, dict[str, Any]]]: + """Get exposed entities. + + Splits out calendars and scripts. + """ area_registry = ar.async_get(hass) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -480,12 +485,13 @@ def _get_exposed_entities( } entities = {} + data: dict[str, dict[str, Any]] = { + SCRIPT_DOMAIN: {}, + CALENDAR_DOMAIN: {}, + } for state in hass.states.async_all(): - if ( - not async_should_expose(hass, assistant, state.entity_id) - or state.domain == SCRIPT_DOMAIN - ): + if not async_should_expose(hass, assistant, state.entity_id): continue description: str | None = None @@ -532,9 +538,13 @@ def _get_exposed_entities( }: info["attributes"] = attributes - entities[state.entity_id] = info + if state.domain in data: + data[state.domain][state.entity_id] = info + else: + entities[state.entity_id] = info - return entities + data["entities"] = entities + return data def _selector_serializer(schema: Any) -> Any: # noqa: C901 @@ -816,15 +826,18 @@ class CalendarGetEventsTool(Tool): name = "calendar_get_events" description = ( "Get events from a calendar. " - "When asked when something happens, search the whole week. " + "When asked if something happens, search the whole week. " "Results are RFC 5545 which means 'end' is exclusive." ) - parameters = vol.Schema( - { - vol.Required("calendar"): cv.string, - vol.Required("range"): vol.In(["today", "week"]), - } - ) + + def __init__(self, calendars: list[str]) -> None: + """Init the get events tool.""" + self.parameters = vol.Schema( + { + vol.Required("calendar"): vol.In(calendars), + vol.Required("range"): vol.In(["today", "week"]), + } + ) async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 943eadff19a..be765ff422d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -6,6 +6,7 @@ from abc import abstractmethod import asyncio from collections.abc import Awaitable, Callable, Coroutine, Generator from datetime import datetime, timedelta +from functools import partial import logging from random import randint from time import monotonic @@ -103,7 +104,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) / 10**6 ) - self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + self._listeners: dict[int, tuple[CALLBACK_TYPE, object | None]] = {} + self._last_listener_id: int = 0 self._unsub_refresh: CALLBACK_TYPE | None = None self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None @@ -148,21 +150,26 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): ) -> Callable[[], None]: """Listen for data updates.""" schedule_refresh = not self._listeners - - @callback - def remove_listener() -> None: - """Remove update listener.""" - self._listeners.pop(remove_listener) - if not self._listeners: - self._unschedule_refresh() - - self._listeners[remove_listener] = (update_callback, context) + self._last_listener_id += 1 + self._listeners[self._last_listener_id] = (update_callback, context) # This is the first listener, set up interval. if schedule_refresh: self._schedule_refresh() - return remove_listener + return partial(self.__async_remove_listener_internal, self._last_listener_id) + + @callback + def __async_remove_listener_internal(self, listener_id: int) -> None: + """Remove a listener. + + This is an internal function that is not to be overridden + in subclasses as it may change in the future. + """ + self._listeners.pop(listener_id) + if not self._listeners: + self._unschedule_refresh() + self._debounced_refresh.async_cancel() @callback def async_update_listeners(self) -> None: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bf9b7262194..05c05d93548 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,10 +3,10 @@ aiodhcpwatcher==1.1.0 aiodiscover==2.2.2 aiodns==3.2.0 -aiohasupervisor==0.2.2b6 -aiohttp-asyncmdnsresolver==0.0.3 +aiohasupervisor==0.3.0 +aiohttp-asyncmdnsresolver==0.1.0 aiohttp-fast-zlib==0.2.2 -aiohttp==3.11.11 +aiohttp==3.11.12 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 @@ -15,7 +15,7 @@ async-interrupt==1.2.1 async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 -audioop-lts==0.2.1;python_version>='3.13' +audioop-lts==0.2.1 av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 @@ -29,16 +29,16 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.32.0 +dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 hass-nabucasa==0.89.0 -hassil==2.2.0 +hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250204.0 -home-assistant-intents==2025.1.28 +home-assistant-frontend==20250205.0 +home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.5 @@ -62,8 +62,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 SQLAlchemy==2.0.37 -standard-aifc==3.13.0;python_version>='3.13' -standard-telnetlib==3.13.0;python_version>='3.13' +standard-aifc==3.13.0 +standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 @@ -168,6 +168,10 @@ pysnmplib==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 +# Poetry is a build dependency. Installing it as a runtime dependency almost +# always indicates an issue with library requirements. +poetry==1000000000.0.0 + # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 1fa93a80cd5..dc4d0988b91 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -132,7 +132,13 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Keep track of domains which will load but have not yet finished loading """ setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_done_futures.update({domain: hass.loop.create_future() for domain in domains}) + setup_futures = hass.data.setdefault(DATA_SETUP, {}) + old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components + if overlap := old_domains & domains: + _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) + setup_done_futures.update( + {domain: hass.loop.create_future() for domain in domains - old_domains} + ) def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool: diff --git a/mypy.ini b/mypy.ini index ddc5589dc09..2d9821b1c64 100644 --- a/mypy.ini +++ b/mypy.ini @@ -945,6 +945,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.brother.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index f1baf85cdf3..c6c506dbff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,17 +27,17 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.2b6", - "aiohttp==3.11.11", + "aiohasupervisor==0.3.0", + "aiohttp==3.11.12", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.2", - "aiohttp-asyncmdnsresolver==0.0.3", + "aiohttp-asyncmdnsresolver==0.1.0", "aiozoneinfo==0.2.3", "astral==2.2", "async-interrupt==1.2.1", "attrs==25.1.0", "atomicwrites-homeassistant==1.4.1", - "audioop-lts==0.2.1;python_version>='3.13'", + "audioop-lts==0.2.1", "awesomeversion==24.6.0", "bcrypt==4.2.0", "certifi>=2021.5.30", @@ -68,8 +68,8 @@ dependencies = [ "requests==2.32.3", "securetar==2025.1.4", "SQLAlchemy==2.0.37", - "standard-aifc==3.13.0;python_version>='3.13'", - "standard-telnetlib==3.13.0;python_version>='3.13'", + "standard-aifc==3.13.0", + "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", "ulid-transform==1.2.0", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 diff --git a/requirements.txt b/requirements.txt index 1a80837e2cc..bd54a380fd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,17 +4,17 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.2b6 -aiohttp==3.11.11 +aiohasupervisor==0.3.0 +aiohttp==3.11.12 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.2 -aiohttp-asyncmdnsresolver==0.0.3 +aiohttp-asyncmdnsresolver==0.1.0 aiozoneinfo==0.2.3 astral==2.2 async-interrupt==1.2.1 attrs==25.1.0 atomicwrites-homeassistant==1.4.1 -audioop-lts==0.2.1;python_version>='3.13' +audioop-lts==0.2.1 awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 @@ -40,8 +40,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2025.1.4 SQLAlchemy==2.0.37 -standard-aifc==3.13.0;python_version>='3.13' -standard-telnetlib==3.13.0;python_version>='3.13' +standard-aifc==3.13.0 +standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 diff --git a/requirements_all.txt b/requirements_all.txt index b643ebc3a87..4c3c2b42424 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -176,7 +176,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 @@ -261,7 +261,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.home_connect aiohomeconnect==0.12.3 @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.2 +aioshelly==12.4.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -650,7 +650,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.0 +bring-api==1.0.1 # homeassistant.components.broadlink broadlink==0.19.0 @@ -738,7 +738,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 @@ -785,7 +785,7 @@ directv==0.4.0 discogs-client==2.3.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 @@ -821,10 +821,10 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.5 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 @@ -1036,7 +1036,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.1 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -1052,10 +1052,10 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.1 +govee-ble==0.43.0 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.1 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 @@ -1100,7 +1100,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.4 +habiticalib==0.3.5 # homeassistant.components.bluetooth habluetooth==3.21.1 @@ -1112,7 +1112,7 @@ hass-nabucasa==0.89.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -1143,13 +1143,13 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 @@ -1241,7 +1241,7 @@ iperf3==0.1.11 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 @@ -1284,7 +1284,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.eufy lakeside==0.13 @@ -1559,7 +1559,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1957,7 +1957,7 @@ pyfibaro==0.8.0 pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 @@ -2309,11 +2309,14 @@ pysmartthings==0.7.8 # homeassistant.components.smarty pysmarty2==0.10.1 +# homeassistant.components.smhi +pysmhi==1.0.0 + # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.2 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 @@ -2440,7 +2443,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.6.0 +python-overseerr==0.7.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -2606,7 +2609,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2738,9 +2741,6 @@ slixmpp==1.8.5 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 -# homeassistant.components.smhi -smhi-pkg==1.0.19 - # homeassistant.components.snapcast snapcast==2.3.6 @@ -2857,7 +2857,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2884,7 +2884,7 @@ thermopro-ble==0.11.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tikteck tikteck==0.4 @@ -3134,7 +3134,7 @@ zeroconf==0.143.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.47 +zha==0.0.48 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index 16983de5706..2731114043b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.10 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a1 +mypy-dev==1.16.0a2 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 522ceac309d..ef9b2978459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.55.4 +PySwitchbot==0.56.0 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -164,7 +164,7 @@ aio-georss-gdacs==0.10 aioacaia==0.1.14 # homeassistant.components.airq -aioairq==0.4.3 +aioairq==0.4.4 # homeassistant.components.airzone_cloud aioairzone-cloud==0.6.10 @@ -246,7 +246,7 @@ aioguardian==2022.07.0 aioharmony==0.4.1 # homeassistant.components.hassio -aiohasupervisor==0.2.2b6 +aiohasupervisor==0.3.0 # homeassistant.components.home_connect aiohomeconnect==0.12.3 @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.2 +aioshelly==12.4.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -570,7 +570,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.0 +bring-api==1.0.1 # homeassistant.components.broadlink broadlink==0.19.0 @@ -634,7 +634,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.32.0 +dbus-fast==2.33.0 # homeassistant.components.debugpy debugpy==1.8.11 @@ -672,7 +672,7 @@ dio-chacon-wifi-api==1.2.1 directv==0.4.0 # homeassistant.components.steamist -discovery30303==0.3.2 +discovery30303==0.3.3 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 @@ -699,10 +699,10 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.5 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 @@ -886,7 +886,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.1 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 @@ -902,10 +902,10 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.1 +govee-ble==0.43.0 # homeassistant.components.govee_light_local -govee-local-api==1.5.3 +govee-local-api==2.0.1 # homeassistant.components.gpsd gps3==0.33.3 @@ -941,7 +941,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.4 +habiticalib==0.3.5 # homeassistant.components.bluetooth habluetooth==3.21.1 @@ -950,7 +950,7 @@ habluetooth==3.21.1 hass-nabucasa==0.89.0 # homeassistant.components.conversation -hassil==2.2.0 +hassil==2.2.3 # homeassistant.components.jewish_calendar hdate==0.11.1 @@ -972,13 +972,13 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250204.0 +home-assistant-frontend==20250205.0 # homeassistant.components.conversation -home-assistant-intents==2025.1.28 +home-assistant-intents==2025.2.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 @@ -1052,7 +1052,7 @@ iottycloud==0.3.0 isal==1.7.1 # homeassistant.components.gogogate2 -ismartgate==5.0.1 +ismartgate==5.0.2 # homeassistant.components.israel_rail israel-rail-api==0.1.2 @@ -1086,7 +1086,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.laundrify laundrify-aio==1.2.2 @@ -1307,7 +1307,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.4 +onedrive-personal-sdk==0.0.8 # homeassistant.components.onvif onvif-zeep-async==3.2.5 @@ -1595,7 +1595,7 @@ pyfibaro==0.8.0 pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 @@ -1881,11 +1881,14 @@ pysmartthings==0.7.8 # homeassistant.components.smarty pysmarty2==0.10.1 +# homeassistant.components.smhi +pysmhi==1.0.0 + # homeassistant.components.edl21 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.2 +pysmlight==0.2.3 # homeassistant.components.snmp pysnmp==6.2.6 @@ -1976,7 +1979,7 @@ python-opensky==1.0.1 python-otbr-api==2.7.0 # homeassistant.components.overseerr -python-overseerr==0.6.0 +python-overseerr==0.7.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -2109,7 +2112,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.8 +reolink-aio==0.11.10 # homeassistant.components.rflink rflink==0.0.66 @@ -2205,9 +2208,6 @@ slack_sdk==3.33.4 # homeassistant.components.smart_meter_texas smart-meter-texas==0.5.5 -# homeassistant.components.smhi -smhi-pkg==1.0.19 - # homeassistant.components.snapcast snapcast==2.3.6 @@ -2297,7 +2297,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.6 +tesla-fleet-api==0.9.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 @@ -2318,7 +2318,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.11.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.2 +thinqconnect==1.0.4 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -2523,7 +2523,7 @@ zeroconf==0.143.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.47 +zha==0.0.48 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ef57b9140ce..fa823fa4834 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -199,6 +199,10 @@ pysnmplib==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 +# Poetry is a build dependency. Installing it as a runtime dependency almost +# always indicates an issue with library requirements. +poetry==1000000000.0.0 + # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 22eae847706..5598c839257 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.1 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py index 27107c7d2e9..9304229dbe3 100644 --- a/tests/components/assist_satellite/test_intent.py +++ b/tests/components/assist_satellite/test_intent.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from .conftest import MockAssistSatellite +from .conftest import TEST_DOMAIN, MockAssistSatellite @pytest.fixture @@ -65,12 +65,7 @@ async def test_broadcast_intent( }, "language": "en", "response_type": "action_done", - "speech": { - "plain": { - "extra_data": None, - "speech": "Done", - } - }, + "speech": {}, # response comes from intents } assert len(entity.announcements) == 1 assert len(entity2.announcements) == 1 @@ -99,12 +94,37 @@ async def test_broadcast_intent( }, "language": "en", "response_type": "action_done", - "speech": { - "plain": { - "extra_data": None, - "speech": "Done", - } - }, + "speech": {}, # response comes from intents } assert len(entity.announcements) == 1 assert len(entity2.announcements) == 2 + + +async def test_broadcast_intent_excluded_domains( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test that the broadcast intent filters out entities in excluded domains.""" + + # Exclude the "test" domain + with patch( + "homeassistant.components.assist_satellite.intent.EXCLUDED_DOMAINS", + new={TEST_DOMAIN}, + ): + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [], # no satellites + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": {}, + } diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index c441cae292c..38b61ce65ea 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -103,9 +103,7 @@ async def test_upload( assert resp.status == 201 assert open_mock.call_count == 1 assert move_mock.call_count == 1 - assert ( - move_mock.mock_calls[0].args[1].name == "Test_-_1970-01-01_00.00_00000000.tar" - ) + assert move_mock.mock_calls[0].args[1].name == "Test_1970-01-01_00.00_00000000.tar" @pytest.mark.usefixtures("read_backup") diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 57f11ed4708..bdcb9f068b6 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -46,6 +46,7 @@ from homeassistant.components.backup.manager import ( RestoreBackupState, WrittenBackup, ) +from homeassistant.components.backup.util import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -359,8 +360,14 @@ async def test_create_backup_when_busy( @pytest.mark.parametrize( ("parameters", "expected_error"), [ - ({"agent_ids": []}, "At least one agent must be selected"), - ({"agent_ids": ["non_existing"]}, "Invalid agents selected: ['non_existing']"), + ( + {"agent_ids": []}, + "At least one available backup agent must be selected, got []", + ), + ( + {"agent_ids": ["non_existing"]}, + "At least one available backup agent must be selected, got ['non_existing']", + ), ( {"include_addons": ["ssl"], "include_all_addons": True}, "Cannot include all addons and specify specific addons", @@ -410,6 +417,8 @@ async def test_create_backup_wrong_parameters( "name", "expected_name", "expected_filename", + "expected_agent_ids", + "expected_failed_agent_ids", "temp_file_unlink_call_count", ), [ @@ -418,7 +427,9 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -427,6 +438,8 @@ async def test_create_backup_wrong_parameters( None, "Custom backup 2025.1.0", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -434,7 +447,9 @@ async def test_create_backup_wrong_parameters( "backups", None, "Custom backup 2025.1.0", - "Custom_backup_2025.1.0_-_2025-01-30_05.42_12345678.tar", + "Custom_backup_2025.1.0_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], 0, ), ( @@ -442,7 +457,9 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + [], 0, ), ( @@ -451,6 +468,8 @@ async def test_create_backup_wrong_parameters( "custom_name", "custom_name", "abc123.tar", # We don't use friendly name for temporary backups + ["test.remote"], + [], 1, ), ( @@ -458,7 +477,20 @@ async def test_create_backup_wrong_parameters( "backups", "custom_name", "custom_name", - "custom_name_-_2025-01-30_05.42_12345678.tar", + "custom_name_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID, "test.remote"], + [], + 0, + ), + ( + # Test we create a backup when at least one agent is available + [LOCAL_AGENT_ID, "test.unavailable"], + "backups", + "custom_name", + "custom_name", + "custom_name_2025-01-30_05.42_12345678.tar", + [LOCAL_AGENT_ID], + ["test.unavailable"], 0, ), ], @@ -486,6 +518,8 @@ async def test_initiate_backup( name: str | None, expected_name: str, expected_filename: str, + expected_agent_ids: list[str], + expected_failed_agent_ids: list[str], temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" @@ -620,13 +654,13 @@ async def test_initiate_backup( "addons": [], "agents": { agent_id: {"protected": bool(password), "size": ANY} - for agent_id in agent_ids + for agent_id in expected_agent_ids }, "backup_id": backup_id, "database_included": include_database, "date": ANY, "extra_metadata": {"instance_id": "our_uuid", "with_automatic_settings": False}, - "failed_agent_ids": [], + "failed_agent_ids": expected_failed_agent_ids, "folders": [], "homeassistant_included": True, "homeassistant_version": "2025.1.0", @@ -959,6 +993,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: @pytest.mark.parametrize( ( + "automatic_agents", "create_backup_command", "create_backup_side_effect", "agent_upload_side_effect", @@ -968,6 +1003,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: [ # No error ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, None, @@ -975,14 +1011,38 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, None, True, {}, ), + # One agent unavailable + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate", "agent_ids": ["test.remote", "test.unknown"]}, + None, + None, + True, + {}, + ), + ( + ["test.remote", "test.unknown"], + {"type": "backup/generate_with_automatic_settings"}, + None, + None, + True, + { + (DOMAIN, "automatic_backup_failed"): { + "translation_key": "automatic_backup_failed_upload_agents", + "translation_placeholders": {"failed_agents": "test.unknown"}, + } + }, + ), # Error raised in async_initiate_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, Exception("Boom!"), None, @@ -990,6 +1050,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, Exception("Boom!"), None, @@ -1003,6 +1064,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised when awaiting the backup task ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, delayed_boom, None, @@ -1010,6 +1072,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, delayed_boom, None, @@ -1023,6 +1086,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: ), # Error raised in async_upload_backup ( + ["test.remote"], {"type": "backup/generate", "agent_ids": ["test.remote"]}, None, Exception("Boom!"), @@ -1030,6 +1094,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: {}, ), ( + ["test.remote"], {"type": "backup/generate_with_automatic_settings"}, None, Exception("Boom!"), @@ -1047,6 +1112,7 @@ async def test_create_backup_failure_raises_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, create_backup: AsyncMock, + automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, agent_upload_side_effect: Exception | None, @@ -1077,7 +1143,7 @@ async def test_create_backup_failure_raises_issue( await ws_client.send_json_auto_id( { "type": "backup/config/update", - "create_backup": {"agent_ids": ["test.remote"]}, + "create_backup": {"agent_ids": automatic_agents}, } ) result = await ws_client.receive_json() @@ -1611,7 +1677,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local&agent_id=test.remote", 2, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, b"test", 0, @@ -1620,7 +1686,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: "agent_id=backup.local", 1, 1, - ["Test_-_1970-01-01_00.00_00000000.tar"], + ["Test_1970-01-01_00.00_00000000.tar"], {}, None, 0, @@ -3142,17 +3208,21 @@ async def test_restore_backup_file_error( @pytest.mark.parametrize( - ("commands", "password", "protected_backup"), + ("commands", "agent_ids", "password", "protected_backup", "inner_tar_key"), [ ( [], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, ), ( [], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3164,8 +3234,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": False}, + None, # None of the agents are protected ), ( [ @@ -3177,8 +3249,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": False, "test.remote": True}, + None, # Local agent is not protected ), ( [ @@ -3190,8 +3264,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": False}, + password_to_key("hunter2"), # Local agent is protected ), ( [ @@ -3203,8 +3279,10 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], "hunter2", {"backup.local": True, "test.remote": True}, + password_to_key("hunter2"), ), ( [ @@ -3216,8 +3294,40 @@ async def test_restore_backup_file_error( }, } ], + ["backup.local", "test.remote"], None, {"backup.local": False, "test.remote": False}, + None, # No password supplied + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": True}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": True}, + password_to_key("hunter2"), + ), + ( + [ + { + "type": "backup/config/update", + "agents": { + "backup.local": {"protected": False}, + "test.remote": {"protected": False}, + }, + } + ], + ["test.remote"], + "hunter2", + {"test.remote": False}, + password_to_key("hunter2"), # Temporary backup protected when password set ), ], ) @@ -3226,13 +3336,15 @@ async def test_initiate_backup_per_agent_encryption( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, generate_backup_id: MagicMock, + mocked_tarfile: Mock, path_glob: MagicMock, commands: dict[str, Any], + agent_ids: list[str], password: str | None, protected_backup: dict[str, bool], + inner_tar_key: bytes | None, ) -> None: """Test generate backup where encryption is selectively set on agents.""" - agent_ids = ["backup.local", "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) remote_agent = BackupAgentTest("remote", backups=[]) @@ -3308,6 +3420,10 @@ async def test_initiate_backup_per_agent_encryption( await hass.async_block_till_done() + mocked_tarfile.return_value.create_inner_tar.assert_called_once_with( + ANY, gzip=True, key=inner_tar_key + ) + result = await ws_client.receive_json() assert result["event"] == { "manager_state": BackupManagerState.CREATE_BACKUP, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 3b188ff8226..504e0d56d58 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -529,10 +529,10 @@ async def test_encrypted_backup_streamer_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("name", "resulting_filename"), [ - ("test", "test_-_2025-01-30_13.42_12345678.tar"), - (" leading spaces", "leading_spaces_-_2025-01-30_13.42_12345678.tar"), - ("trailing spaces ", "trailing_spaces_-_2025-01-30_13.42_12345678.tar"), - ("double spaces ", "double_spaces_-_2025-01-30_13.42_12345678.tar"), + ("test", "test_2025-01-30_13.42_12345678.tar"), + (" leading spaces", "leading_spaces_2025-01-30_13.42_12345678.tar"), + ("trailing spaces ", "trailing_spaces_2025-01-30_13.42_12345678.tar"), + ("double spaces ", "double_spaces_2025-01-30_13.42_12345678.tar"), ], ) def test_suggested_filename(name: str, resulting_filename: str) -> None: diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 2b2e9257097..da630f7fbc8 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -4,11 +4,13 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch import uuid -from bring_api.types import ( +from bring_api import ( + BringActivityResponse, BringAuthResponse, BringItemsResponse, BringListResponse, BringUserSettingsResponse, + BringUsersResponse, ) import pytest @@ -60,6 +62,13 @@ def mock_bring_client() -> Generator[AsyncMock]: client.get_all_user_settings.return_value = BringUserSettingsResponse.from_json( load_fixture("usersettings.json", DOMAIN) ) + client.get_activity.return_value = BringActivityResponse.from_json( + load_fixture("activity.json", DOMAIN) + ) + client.get_list_users.return_value = BringUsersResponse.from_json( + load_fixture("users.json", DOMAIN) + ) + yield client diff --git a/tests/components/bring/fixtures/activity.json b/tests/components/bring/fixtures/activity.json new file mode 100644 index 00000000000..5e9a8c089d3 --- /dev/null +++ b/tests/components/bring/fixtures/activity.json @@ -0,0 +1,62 @@ +{ + "timeline": [ + { + "type": "LIST_ITEMS_CHANGED", + "content": { + "uuid": "673594a9-f92d-4cb6-adf1-d2f7a83207a4", + "purchase": [ + { + "uuid": "658a3770-1a03-4ee0-94a6-10362a642377", + "itemId": "Gurke", + "specification": "", + "attributes": [] + } + ], + "recently": [ + { + "uuid": "1ed22d3d-f19b-4530-a518-19872da3fd3e", + "itemId": "Milch", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T03:09:33.036Z", + "publicUserUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d" + } + }, + { + "type": "LIST_ITEMS_ADDED", + "content": { + "uuid": "9a16635c-dea2-4e00-904a-c5034f9cfecf", + "items": [ + { + "uuid": "66a633a2-ae09-47bf-8845-3c0198480544", + "itemId": "Joghurt", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T02:54:57.656Z", + "publicUserUuid": "73af455f-c158-4004-a5e0-79f4f8a6d4bd" + } + }, + { + "type": "LIST_ITEMS_REMOVED", + "content": { + "uuid": "303dedf6-d4b2-4d25-a8cd-1c7967b84fcb", + "items": [ + { + "uuid": "2ba8ddb6-01c6-4b0b-a89d-f3da6b291528", + "itemId": "Tofu", + "specification": "", + "attributes": [] + } + ], + "sessionDate": "2025-01-01T03:09:12.380Z", + "publicUserUuid": "7d5e9d08-877a-4c36-8740-a9bf74ec690a" + } + } + ], + "timestamp": "2025-01-01T03:09:33.036Z", + "totalEvents": 3 +} diff --git a/tests/components/bring/fixtures/users.json b/tests/components/bring/fixtures/users.json new file mode 100644 index 00000000000..c9393dcb20d --- /dev/null +++ b/tests/components/bring/fixtures/users.json @@ -0,0 +1,31 @@ +{ + "users": [ + { + "publicUuid": "9a21fdfc-63a4-441a-afc1-ef3030605a9d", + "name": "Bring", + "email": "test-email", + "photoPath": "", + "pushEnabled": true, + "plusTryOut": false, + "country": "DE", + "language": "de" + }, + { + "publicUuid": "73af455f-c158-4004-a5e0-79f4f8a6d4bd", + "name": "NAME", + "email": "EMAIL", + "photoPath": "", + "pushEnabled": true, + "plusTryOut": false, + "country": "US", + "language": "en" + }, + { + "publicUuid": "7d5e9d08-877a-4c36-8740-a9bf74ec690a", + "pushEnabled": true, + "plusTryOut": false, + "country": "US", + "language": "en" + } + ] +} diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 740f4902fc3..951c3d3f808 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -1,113 +1,407 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ - 'content': dict({ - 'items': dict({ - 'purchase': list([ + 'data': dict({ + 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ + 'activity': dict({ + 'timeline': list([ dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, + 'content': dict({ + 'items': list([ + ]), + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': '2025-01-01T03:09:33.036000+00:00', + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'type': 'LIST_ITEMS_CHANGED', }), dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Joghurt', + 'specification': '', + 'uuid': '66a633a2-ae09-47bf-8845-3c0198480544', }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + ]), + 'publicUserUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T02:54:57.656000+00:00', + 'uuid': '9a16635c-dea2-4e00-904a-c5034f9cfecf', + }), + 'type': 'LIST_ITEMS_ADDED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Tofu', + 'specification': '', + 'uuid': '2ba8ddb6-01c6-4b0b-a89d-f3da6b291528', + }), + ]), + 'publicUserUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T03:09:12.380000+00:00', + 'uuid': '303dedf6-d4b2-4d25-a8cd-1c7967b84fcb', + }), + 'type': 'LIST_ITEMS_REMOVED', }), ]), - 'recently': list([ + 'timestamp': '2025-01-01T03:09:33.036000+00:00', + 'totalEvents': 3, + }), + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + }), + 'lst': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + 'users': dict({ + 'users': list([ dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + 'country': 'DE', + 'email': 'test-email', + 'language': 'de', + 'name': 'Bring', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': 'EMAIL', + 'language': 'en', + 'name': 'NAME', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, }), ]), }), - 'status': 'REGISTERED', - 'uuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', }), - 'lst': dict({ - 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', - 'theme': 'ch.publisheria.bring.theme.home', + 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ + 'activity': dict({ + 'timeline': list([ + dict({ + 'content': dict({ + 'items': list([ + ]), + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': '2025-01-01T03:09:33.036000+00:00', + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'type': 'LIST_ITEMS_CHANGED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Joghurt', + 'specification': '', + 'uuid': '66a633a2-ae09-47bf-8845-3c0198480544', + }), + ]), + 'publicUserUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T02:54:57.656000+00:00', + 'uuid': '9a16635c-dea2-4e00-904a-c5034f9cfecf', + }), + 'type': 'LIST_ITEMS_ADDED', + }), + dict({ + 'content': dict({ + 'items': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Tofu', + 'specification': '', + 'uuid': '2ba8ddb6-01c6-4b0b-a89d-f3da6b291528', + }), + ]), + 'publicUserUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'purchase': list([ + ]), + 'recently': list([ + ]), + 'sessionDate': '2025-01-01T03:09:12.380000+00:00', + 'uuid': '303dedf6-d4b2-4d25-a8cd-1c7967b84fcb', + }), + 'type': 'LIST_ITEMS_REMOVED', + }), + ]), + 'timestamp': '2025-01-01T03:09:33.036000+00:00', + 'totalEvents': 3, + }), + 'content': dict({ + 'items': dict({ + 'purchase': list([ + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + }), + 'status': 'REGISTERED', + 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + }), + 'lst': dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'theme': 'ch.publisheria.bring.theme.home', + }), + 'users': dict({ + 'users': list([ + dict({ + 'country': 'DE', + 'email': 'test-email', + 'language': 'de', + 'name': 'Bring', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': 'EMAIL', + 'language': 'en', + 'name': 'NAME', + 'photoPath': '', + 'plusTryOut': False, + 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', + 'pushEnabled': True, + }), + dict({ + 'country': 'US', + 'email': None, + 'language': 'en', + 'name': None, + 'photoPath': None, + 'plusTryOut': False, + 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', + 'pushEnabled': True, + }), + ]), + }), }), }), - 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ - 'content': dict({ - 'items': dict({ - 'purchase': list([ - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Paprika', - 'specification': 'Rot', - 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', - }), - dict({ - 'attributes': list([ - dict({ - 'content': dict({ - 'convenient': True, - 'discounted': True, - 'urgent': True, - }), - 'type': 'PURCHASE_CONDITIONS', - }), - ]), - 'itemId': 'Pouletbrüstli', - 'specification': 'Bio', - 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', - }), - ]), - 'recently': list([ - dict({ - 'attributes': list([ - ]), - 'itemId': 'Ananas', - 'specification': '', - 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', - }), - ]), - }), - 'status': 'REGISTERED', - 'uuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - }), - 'lst': dict({ + 'lists': list([ + dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', 'name': 'Einkauf', 'theme': 'ch.publisheria.bring.theme.home', }), + dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'theme': 'ch.publisheria.bring.theme.home', + }), + ]), + 'user_settings': dict({ + 'userlistsettings': list([ + dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'usersettings': list([ + dict({ + 'key': 'listSectionOrder', + 'value': '["Früchte & Gemüse","Brot & Gebäck","Milch & Käse","Fleisch & Fisch","Zutaten & Gewürze","Fertig- & Tiefkühlprodukte","Getreideprodukte","Snacks & Süsswaren","Getränke & Tabak","Haushalt & Gesundheit","Pflege & Gesundheit","Tierbedarf","Baumarkt & Garten","Eigene Artikel"]', + }), + dict({ + 'key': 'listArticleLanguage', + 'value': 'de-DE', + }), + ]), + }), + dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'usersettings': list([ + dict({ + 'key': 'listSectionOrder', + 'value': '["Früchte & Gemüse","Brot & Gebäck","Milch & Käse","Fleisch & Fisch","Zutaten & Gewürze","Fertig- & Tiefkühlprodukte","Getreideprodukte","Snacks & Süsswaren","Getränke & Tabak","Haushalt & Gesundheit","Pflege & Gesundheit","Tierbedarf","Baumarkt & Garten","Eigene Artikel"]', + }), + dict({ + 'key': 'listArticleLanguage', + 'value': 'en-US', + }), + ]), + }), + ]), + 'usersettings': list([ + dict({ + 'key': 'autoPush', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideOffersBadge', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideSponsoredCategories', + 'value': 'ON', + }), + dict({ + 'key': 'premiumHideInspirationsBadge', + 'value': 'ON', + }), + dict({ + 'key': 'onboardClient', + 'value': 'android', + }), + dict({ + 'key': 'premiumHideOffersOnMain', + 'value': 'ON', + }), + dict({ + 'key': 'defaultListUUID', + 'value': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + }), + ]), }), }) # --- diff --git a/tests/components/bring/snapshots/test_event.ambr b/tests/components/bring/snapshots/test_event.ambr new file mode 100644 index 00000000000..907467bd6bb --- /dev/null +++ b/tests/components/bring/snapshots/test_event.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_setup[event.baumarkt_activities-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.baumarkt_activities', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activities', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activities', + 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_activities', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.baumarkt_activities-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://api.getbring.com/rest/v2/bringusers/profilepictures/9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'event_type': 'list_items_changed', + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + 'friendly_name': 'Baumarkt Activities', + 'items': list([ + ]), + 'last_activity_by': 'Bring', + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': HAFakeDatetime(2025, 1, 1, 3, 9, 33, 36000, tzinfo=datetime.timezone.utc), + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'context': , + 'entity_id': 'event.baumarkt_activities', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T03:30:00.000+00:00', + }) +# --- +# name: test_setup[event.einkauf_activities-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.einkauf_activities', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activities', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activities', + 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_activities', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[event.einkauf_activities-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://api.getbring.com/rest/v2/bringusers/profilepictures/9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'event_type': 'list_items_changed', + 'event_types': list([ + 'list_items_changed', + 'list_items_added', + 'list_items_removed', + ]), + 'friendly_name': 'Einkauf Activities', + 'items': list([ + ]), + 'last_activity_by': 'Bring', + 'publicUserUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Gurke', + 'specification': '', + 'uuid': '658a3770-1a03-4ee0-94a6-10362a642377', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Milch', + 'specification': '', + 'uuid': '1ed22d3d-f19b-4530-a518-19872da3fd3e', + }), + ]), + 'sessionDate': HAFakeDatetime(2025, 1, 1, 3, 9, 33, 36000, tzinfo=datetime.timezone.utc), + 'uuid': '673594a9-f92d-4cb6-adf1-d2f7a83207a4', + }), + 'context': , + 'entity_id': 'event.einkauf_activities', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T03:30:00.000+00:00', + }) +# --- diff --git a/tests/components/bring/test_config_flow.py b/tests/components/bring/test_config_flow.py index 93e86051a75..b9208324c61 100644 --- a/tests/components/bring/test_config_flow.py +++ b/tests/components/bring/test_config_flow.py @@ -2,11 +2,7 @@ from unittest.mock import AsyncMock -from bring_api.exceptions import ( - BringAuthException, - BringParseException, - BringRequestException, -) +from bring_api import BringAuthException, BringParseException, BringRequestException import pytest from homeassistant.components.bring.const import DOMAIN @@ -214,3 +210,104 @@ async def test_flow_reauth_unique_id_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_flow_reconfigure( + hass: HomeAssistant, bring_config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + bring_config_entry.add_to_hass(hass) + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert bring_config_entry.data[CONF_EMAIL] == "new-email" + assert bring_config_entry.data[CONF_PASSWORD] == "new-password" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (BringRequestException(), "cannot_connect"), + (BringAuthException(), "invalid_auth"), + (BringParseException(), "unknown"), + (IndexError(), "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_bring_client: AsyncMock, + bring_config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors.""" + bring_config_entry.add_to_hass(hass) + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_bring_client.login.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_bring_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert bring_config_entry.data[CONF_EMAIL] == "new-email" + assert bring_config_entry.data[CONF_PASSWORD] == "new-password" + + assert len(hass.config_entries.async_entries()) == 1 + + +async def test_flow_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test we abort reconfigure if unique id mismatch.""" + + mock_bring_client.uuid = "11111111-11111111-11111111-11111111" + + bring_config_entry.add_to_hass(hass) + + result = await bring_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "new-email", CONF_PASSWORD: "new-password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/bring/test_event.py b/tests/components/bring/test_event.py new file mode 100644 index 00000000000..99b96c27153 --- /dev/null +++ b/tests/components/bring/test_event.py @@ -0,0 +1,46 @@ +"""Test for event platform of the Bring! integration.""" + +from collections.abc import Generator +from unittest.mock import patch + +from freezegun.api import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def event_only() -> Generator[None]: + """Enable only the event platform.""" + with patch( + "homeassistant.components.bring.PLATFORMS", + [Platform.EVENT], + ): + yield + + +@pytest.mark.usefixtures("mock_bring_client") +@freeze_time("2025-01-01T03:30:00.000Z") +async def test_setup( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of event platform.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, bring_config_entry.entry_id + ) diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index a77c709315f..f053f294ef1 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -142,7 +142,6 @@ async def test_config_entry_not_ready_udpdate_failed( @pytest.mark.parametrize( ("exception", "state"), [ - (None, ConfigEntryState.LOADED), (BringAuthException, ConfigEntryState.SETUP_ERROR), (BringRequestException, ConfigEntryState.SETUP_RETRY), (BringParseException, ConfigEntryState.SETUP_RETRY), @@ -159,9 +158,8 @@ async def test_config_entry_not_ready_auth_error( mock_bring_client.load_lists.side_effect = [ mock_bring_client.load_lists.return_value, - BringAuthException, + exception, ] - mock_bring_client.retrieve_new_access_token.side_effect = exception bring_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(bring_config_entry.entry_id) diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py index 3060f31c134..673c4e68a4d 100644 --- a/tests/components/bring/test_util.py +++ b/tests/components/bring/test_util.py @@ -1,6 +1,12 @@ """Test for utility functions of the Bring! integration.""" -from bring_api import BringItemsResponse, BringListResponse, BringUserSettingsResponse +from bring_api import ( + BringActivityResponse, + BringItemsResponse, + BringListResponse, + BringUserSettingsResponse, +) +from bring_api.types import BringUsersResponse import pytest from homeassistant.components.bring.const import DOMAIN @@ -41,9 +47,10 @@ def test_sum_attributes(attribute: str, expected: int) -> None: """Test function sum_attributes.""" items = BringItemsResponse.from_json(load_fixture("items.json", DOMAIN)) lst = BringListResponse.from_json(load_fixture("lists.json", DOMAIN)) - + activity = BringActivityResponse.from_json(load_fixture("activity.json", DOMAIN)) + users = BringUsersResponse.from_json(load_fixture("users.json", DOMAIN)) result = sum_attributes( - BringData(lst.lists[0], items), + BringData(lst.lists[0], items, activity, users), attribute, ) diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 7002f7c39ec..276a06a7f46 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -9,6 +9,7 @@ from hass_nabucasa import Cloud from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.files import Files from hass_nabucasa.google_report_state import GoogleReportState from hass_nabucasa.ice_servers import IceServers from hass_nabucasa.iot import CloudIoT @@ -68,6 +69,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED ) mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.files = MagicMock(spec=Files) mock_cloud.started = None mock_cloud.ice_servers = MagicMock( spec=IceServers, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5b2b8751311..6e59b7d983e 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -1,14 +1,15 @@ """Test the cloud backup platform.""" -from collections.abc import AsyncGenerator, AsyncIterator, Generator +from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.api import CloudApiNonRetryableError +from hass_nabucasa.files import FilesError import pytest -from yarl import URL from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -22,11 +23,20 @@ from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -55,49 +65,6 @@ def mock_delete_file() -> Generator[MagicMock]: yield delete_file -@pytest.fixture -def mock_get_download_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_download_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah" - ), - } - yield download_details - - -@pytest.fixture -def mock_get_upload_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_upload_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah" - ), - "headers": { - "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==", - "x-amz-meta-storage-type": "backup", - "x-amz-meta-b64json": ( - "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT" - "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm" - "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm" - "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy" - "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ==" - ), - }, - } - yield download_details - - @pytest.fixture def mock_list_files() -> Generator[MagicMock]: """Mock list files.""" @@ -264,52 +231,30 @@ async def test_agents_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get( - mock_get_download_details.return_value["url"], content=b"backup data" - ) + cloud.files.download.return_value = MockStreamReaderChunked(b"backup data") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_download_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_get_download_details: Mock, - side_effect: Exception, -) -> None: - """Test agent download backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "23e64aec" - mock_get_download_details.side_effect = side_effect - - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") - assert resp.status == 500 - content = await resp.content.read() - assert "Failed to get download details" in content.decode() - - @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download_fail_get( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup, when cloud user is logged in.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get(mock_get_download_details.return_value["url"], status=500) + cloud.files.download.side_effect = FilesError("Oh no :(") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 500 @@ -336,8 +281,7 @@ async def test_agents_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, + cloud: Mock, ) -> None: """Test agent upload backup.""" client = await hass_client() @@ -355,8 +299,6 @@ async def test_agents_upload( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"]) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -374,26 +316,22 @@ async def test_agents_upload( data={"file": StringIO("test")}, ) - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][0] == "PUT" - assert aioclient_mock.mock_calls[-1][1] == URL( - mock_get_upload_details.return_value["url"] - ) - assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator) + assert len(cloud.files.upload.mock_calls) == 1 + metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] + assert metadata["backup_id"] == backup_id assert resp.status == 201 assert f"Uploading backup {backup_id}" in caplog.text -@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}]) +@pytest.mark.parametrize("side_effect", [FilesError("Boom!"), CloudError("Boom!")]) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_upload_fail_put( +async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, - put_mock_kwargs: dict[str, Any], + side_effect: Exception, + cloud: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" @@ -412,7 +350,8 @@ async def test_agents_upload_fail_put( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) + + cloud.files.upload.side_effect = side_effect with ( patch( @@ -435,9 +374,9 @@ async def test_agents_upload_fail_put( ) await hass.async_block_till_done() - assert len(aioclient_mock.mock_calls) == 2 assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 + assert cloud.files.upload.call_count == 2 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] @@ -445,19 +384,32 @@ async def test_agents_upload_fail_put( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in") -async def test_agents_upload_fail_cloud( +@pytest.mark.parametrize( + ("side_effect", "logmsg"), + [ + ( + CloudApiNonRetryableError("Boom!", code="NC-SH-FH-03"), + "The backup size of 13.37GB is too large to be uploaded to Home Assistant Cloud", + ), + ( + CloudApiNonRetryableError("Boom!", code="NC-CE-01"), + "Failed to upload backup Boom!", + ), + ], +) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_fail_non_retryable( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], - mock_get_upload_details: Mock, side_effect: Exception, + logmsg: str, + cloud: Mock, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test agent upload backup, when cloud user is logged in.""" + """Test agent upload backup fails with non-retryable error.""" client = await hass_client() backup_id = "test-backup" - mock_get_upload_details.side_effect = side_effect test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], backup_id=backup_id, @@ -469,8 +421,11 @@ async def test_agents_upload_fail_cloud( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=14358124749, ) + + cloud.files.upload.side_effect = side_effect + with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -480,7 +435,6 @@ async def test_agents_upload_fail_cloud( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.cloud.backup.asyncio.sleep"), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup @@ -490,7 +444,9 @@ async def test_agents_upload_fail_cloud( ) await hass.async_block_till_done() + assert logmsg in caplog.text assert resp.status == 201 + assert cloud.files.upload.call_count == 1 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] diff --git a/tests/components/electric_kiwi/__init__.py b/tests/components/electric_kiwi/__init__.py index 7f5e08a56b5..936557ac3bf 100644 --- a/tests/components/electric_kiwi/__init__.py +++ b/tests/components/electric_kiwi/__init__.py @@ -1 +1,13 @@ """Tests for the Electric Kiwi integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Fixture for setting up the integration with args.""" + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 010efcb7b5f..cc967631be4 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -2,11 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from time import time from unittest.mock import AsyncMock, patch -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import ( + AccountSummary, + CustomerConnection, + Hop, + HopIntervals, + Service, + Session, +) import pytest from homeassistant.components.application_credentials import ( @@ -23,37 +30,55 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -type YieldFixture = Generator[AsyncMock] -type ComponentSetup = Callable[[], Awaitable[bool]] + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) @pytest.fixture(autouse=True) -async def request_setup(current_request_with_host: None) -> None: - """Request setup.""" - - -@pytest.fixture -def component_setup( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> ComponentSetup: - """Fixture for setting up the integration.""" - - async def _setup_func() -> bool: - assert await async_setup_component(hass, "application_credentials", {}) - await hass.async_block_till_done() - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - DOMAIN, +def electrickiwi_api() -> Generator[AsyncMock]: + """Mock ek api and return values.""" + with ( + patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.electric_kiwi.config_flow.ElectricKiwiApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.customer_number = 123456 + client.electricity = Service( + identifier="00000000DDA", + service="electricity", + service_status="Y", + is_primary_service=True, ) - await hass.async_block_till_done() - config_entry.add_to_hass(hass) - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return result - - return _setup_func + client.get_active_session.return_value = Session.from_dict( + load_json_value_fixture("session.json", DOMAIN) + ) + client.get_hop_intervals.return_value = HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + client.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + client.get_account_summary.return_value = AccountSummary.from_dict( + load_json_value_fixture("account_summary.json", DOMAIN) + ) + client.get_connection_details.return_value = CustomerConnection.from_dict( + load_json_value_fixture("connection_details.json", DOMAIN) + ) + yield client @pytest.fixture(name="config_entry") @@ -63,7 +88,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "12345", + "id": "123456", "auth_implementation": DOMAIN, "token": { "refresh_token": "mock-refresh-token", @@ -74,6 +99,54 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, }, unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="config_entry2") +def mock_config_entry2(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123457", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="1234567", + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="migrated_config_entry") +def mock_migrated_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123456", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="123456", + version=1, + minor_version=2, ) @@ -87,35 +160,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="ek_auth") -def electric_kiwi_auth() -> YieldFixture: +def electric_kiwi_auth() -> Generator[AsyncMock]: """Patch access to electric kiwi access token.""" with patch( - "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + "homeassistant.components.electric_kiwi.api.ConfigEntryElectricKiwiAuth" ) as mock_auth: mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") yield mock_auth - - -@pytest.fixture(name="ek_api") -def ek_api() -> YieldFixture: - """Mock ek api and return values.""" - with patch( - "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True - ) as mock_ek_api: - mock_ek_api.return_value.customer_number = 123456 - mock_ek_api.return_value.connection_id = 123456 - mock_ek_api.return_value.set_active_session.return_value = None - mock_ek_api.return_value.get_hop_intervals.return_value = ( - HopIntervals.from_dict( - load_json_value_fixture("hop_intervals.json", DOMAIN) - ) - ) - mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( - load_json_value_fixture("get_hop.json", DOMAIN) - ) - mock_ek_api.return_value.get_account_balance.return_value = ( - AccountBalance.from_dict( - load_json_value_fixture("account_balance.json", DOMAIN) - ) - ) - yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/account_balance.json b/tests/components/electric_kiwi/fixtures/account_balance.json deleted file mode 100644 index 25bc57784ee..00000000000 --- a/tests/components/electric_kiwi/fixtures/account_balance.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "data": { - "connections": [ - { - "hop_percentage": "3.5", - "id": 3, - "running_balance": "184.09", - "start_date": "2020-10-04", - "unbilled_days": 15 - } - ], - "last_billed_amount": "-66.31", - "last_billed_date": "2020-10-03", - "next_billing_date": "2020-11-03", - "is_prepay": "N", - "summary": { - "credits": "0.0", - "electricity_used": "184.09", - "other_charges": "0.00", - "payments": "-220.0" - }, - "total_account_balance": "-102.22", - "total_billing_days": 30, - "total_running_balance": "184.09", - "type": "account_running_balance" - }, - "status": 1 -} diff --git a/tests/components/electric_kiwi/fixtures/account_summary.json b/tests/components/electric_kiwi/fixtures/account_summary.json new file mode 100644 index 00000000000..6a05d6a3fe7 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/account_summary.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "account_summary", + "total_running_balance": "184.09", + "total_account_balance": "-102.22", + "total_billing_days": 31, + "next_billing_date": "2025-02-19", + "service_names": ["power"], + "services": { + "power": { + "connections": [ + { + "id": 515363, + "running_balance": "12.98", + "unbilled_days": 5, + "hop_percentage": "11.2", + "start_date": "2025-01-19", + "service_label": "Power" + } + ] + } + }, + "date_to_pay": "", + "invoice_id": "", + "total_invoiced_charges": "", + "default_to_pay": "", + "invoice_exists": 1, + "display_date": "2025-01-19", + "last_billed_date": "2025-01-18", + "last_billed_amount": "-21.02", + "summary": { + "electricity_used": "12.98", + "other_charges": "0.00", + "payments": "0.00", + "credits": "0.00", + "mobile_charges": "0.00", + "broadband_charges": "0.00", + "addon_unbilled_charges": {} + }, + "is_prepay": "N" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/connection_details.json b/tests/components/electric_kiwi/fixtures/connection_details.json new file mode 100644 index 00000000000..5b446659aab --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/connection_details.json @@ -0,0 +1,73 @@ +{ + "data": { + "type": "connection", + "id": 515363, + "customer_id": 273941, + "customer_number": 34030646, + "icp_identifier": "00000000DDA", + "address": "", + "short_address": "", + "physical_address_unit": "", + "physical_address_number": "555", + "physical_address_street": "RACECOURSE ROAD", + "physical_address_suburb": "", + "physical_address_town": "Blah", + "physical_address_region": "Blah", + "physical_address_postcode": "0000", + "is_active": "Y", + "pricing_plan": { + "id": 51423, + "usage": "0.0000", + "fixed": "0.6000", + "usage_rate_inc_gst": "0.0000", + "supply_rate_inc_gst": "0.6900", + "plan_description": "MoveMaster Anytime Residential (Low User)", + "plan_type": "movemaster_tou", + "signup_price_plan_blurb": "Better rates every day during off-peak, and all day on weekends. Plus half price nights (11pm-7am) and our best solar buyback.", + "signup_price_plan_label": "MoveMaster", + "app_price_plan_label": "Your MoveMaster rates are...", + "solar_rate_excl_gst": "0.1250", + "solar_rate_incl_gst": "0.1438", + "pricing_type": "tou_plus", + "tou_plus": { + "fixed_rate_excl_gst": "0.6000", + "fixed_rate_incl_gst": "0.6900", + "interval_types": ["peak", "off_peak_shoulder", "off_peak_night"], + "peak": { + "price_excl_gst": "0.5390", + "price_incl_gst": "0.6199", + "display_text": { + "Weekdays": "7am-9am, 5pm-9pm" + }, + "tou_plus_label": "Peak" + }, + "off_peak_shoulder": { + "price_excl_gst": "0.3234", + "price_incl_gst": "0.3719", + "display_text": { + "Weekdays": "9am-5pm, 9pm-11pm", + "Weekends": "7am-11pm" + }, + "tou_plus_label": "Off-peak shoulder" + }, + "off_peak_night": { + "price_excl_gst": "0.2695", + "price_incl_gst": "0.3099", + "display_text": { + "Every day": "11pm-7am" + }, + "tou_plus_label": "Off-peak night" + } + } + }, + "hop": { + "start_time": "9:00 PM", + "end_time": "10:00 PM", + "interval_start": "43", + "interval_end": "44" + }, + "start_date": "2022-03-03", + "end_date": "", + "property_type": "residential" + } +} diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json index d29825391e9..2b126bfc017 100644 --- a/tests/components/electric_kiwi/fixtures/get_hop.json +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -1,16 +1,18 @@ { "data": { - "connection_id": "3", - "customer_number": 1000001, - "end": { - "end_time": "5:00 PM", - "interval": "34" - }, + "type": "hop_customer", + "customer_id": 123456, + "service_type": "electricity", + "connection_id": 515363, + "billing_id": 1247975, "start": { - "start_time": "4:00 PM", - "interval": "33" + "interval": "33", + "start_time": "4:00 PM" }, - "type": "hop_customer" + "end": { + "interval": "34", + "end_time": "5:00 PM" + } }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json index 15ecc174f13..860630b000a 100644 --- a/tests/components/electric_kiwi/fixtures/hop_intervals.json +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -1,249 +1,250 @@ { "data": { - "hop_duration": "60", "type": "hop_intervals", + "hop_duration": "60", "intervals": { "1": { - "active": 1, + "start_time": "12:00 AM", "end_time": "1:00 AM", - "start_time": "12:00 AM" + "active": 1 }, "2": { - "active": 1, + "start_time": "12:30 AM", "end_time": "1:30 AM", - "start_time": "12:30 AM" + "active": 1 }, "3": { - "active": 1, + "start_time": "1:00 AM", "end_time": "2:00 AM", - "start_time": "1:00 AM" + "active": 1 }, "4": { - "active": 1, + "start_time": "1:30 AM", "end_time": "2:30 AM", - "start_time": "1:30 AM" + "active": 1 }, "5": { - "active": 1, + "start_time": "2:00 AM", "end_time": "3:00 AM", - "start_time": "2:00 AM" + "active": 1 }, "6": { - "active": 1, + "start_time": "2:30 AM", "end_time": "3:30 AM", - "start_time": "2:30 AM" + "active": 1 }, "7": { - "active": 1, + "start_time": "3:00 AM", "end_time": "4:00 AM", - "start_time": "3:00 AM" + "active": 1 }, "8": { - "active": 1, + "start_time": "3:30 AM", "end_time": "4:30 AM", - "start_time": "3:30 AM" + "active": 1 }, "9": { - "active": 1, + "start_time": "4:00 AM", "end_time": "5:00 AM", - "start_time": "4:00 AM" + "active": 1 }, "10": { - "active": 1, + "start_time": "4:30 AM", "end_time": "5:30 AM", - "start_time": "4:30 AM" + "active": 1 }, "11": { - "active": 1, + "start_time": "5:00 AM", "end_time": "6:00 AM", - "start_time": "5:00 AM" + "active": 1 }, "12": { - "active": 1, + "start_time": "5:30 AM", "end_time": "6:30 AM", - "start_time": "5:30 AM" + "active": 1 }, "13": { - "active": 1, + "start_time": "6:00 AM", "end_time": "7:00 AM", - "start_time": "6:00 AM" + "active": 1 }, "14": { - "active": 1, + "start_time": "6:30 AM", "end_time": "7:30 AM", - "start_time": "6:30 AM" + "active": 0 }, "15": { - "active": 1, + "start_time": "7:00 AM", "end_time": "8:00 AM", - "start_time": "7:00 AM" + "active": 0 }, "16": { - "active": 1, + "start_time": "7:30 AM", "end_time": "8:30 AM", - "start_time": "7:30 AM" + "active": 0 }, "17": { - "active": 1, + "start_time": "8:00 AM", "end_time": "9:00 AM", - "start_time": "8:00 AM" + "active": 0 }, "18": { - "active": 1, + "start_time": "8:30 AM", "end_time": "9:30 AM", - "start_time": "8:30 AM" + "active": 0 }, "19": { - "active": 1, + "start_time": "9:00 AM", "end_time": "10:00 AM", - "start_time": "9:00 AM" + "active": 1 }, "20": { - "active": 1, + "start_time": "9:30 AM", "end_time": "10:30 AM", - "start_time": "9:30 AM" + "active": 1 }, "21": { - "active": 1, + "start_time": "10:00 AM", "end_time": "11:00 AM", - "start_time": "10:00 AM" + "active": 1 }, "22": { - "active": 1, + "start_time": "10:30 AM", "end_time": "11:30 AM", - "start_time": "10:30 AM" + "active": 1 }, "23": { - "active": 1, + "start_time": "11:00 AM", "end_time": "12:00 PM", - "start_time": "11:00 AM" + "active": 1 }, "24": { - "active": 1, + "start_time": "11:30 AM", "end_time": "12:30 PM", - "start_time": "11:30 AM" + "active": 1 }, "25": { - "active": 1, + "start_time": "12:00 PM", "end_time": "1:00 PM", - "start_time": "12:00 PM" + "active": 1 }, "26": { - "active": 1, + "start_time": "12:30 PM", "end_time": "1:30 PM", - "start_time": "12:30 PM" + "active": 1 }, "27": { - "active": 1, + "start_time": "1:00 PM", "end_time": "2:00 PM", - "start_time": "1:00 PM" + "active": 1 }, "28": { - "active": 1, + "start_time": "1:30 PM", "end_time": "2:30 PM", - "start_time": "1:30 PM" + "active": 1 }, "29": { - "active": 1, + "start_time": "2:00 PM", "end_time": "3:00 PM", - "start_time": "2:00 PM" + "active": 1 }, "30": { - "active": 1, + "start_time": "2:30 PM", "end_time": "3:30 PM", - "start_time": "2:30 PM" + "active": 1 }, "31": { - "active": 1, + "start_time": "3:00 PM", "end_time": "4:00 PM", - "start_time": "3:00 PM" + "active": 1 }, "32": { - "active": 1, + "start_time": "3:30 PM", "end_time": "4:30 PM", - "start_time": "3:30 PM" + "active": 1 }, "33": { - "active": 1, + "start_time": "4:00 PM", "end_time": "5:00 PM", - "start_time": "4:00 PM" + "active": 1 }, "34": { - "active": 1, + "start_time": "4:30 PM", "end_time": "5:30 PM", - "start_time": "4:30 PM" + "active": 0 }, "35": { - "active": 1, + "start_time": "5:00 PM", "end_time": "6:00 PM", - "start_time": "5:00 PM" + "active": 0 }, "36": { - "active": 1, + "start_time": "5:30 PM", "end_time": "6:30 PM", - "start_time": "5:30 PM" + "active": 0 }, "37": { - "active": 1, + "start_time": "6:00 PM", "end_time": "7:00 PM", - "start_time": "6:00 PM" + "active": 0 }, "38": { - "active": 1, + "start_time": "6:30 PM", "end_time": "7:30 PM", - "start_time": "6:30 PM" + "active": 0 }, "39": { - "active": 1, + "start_time": "7:00 PM", "end_time": "8:00 PM", - "start_time": "7:00 PM" + "active": 0 }, "40": { - "active": 1, + "start_time": "7:30 PM", "end_time": "8:30 PM", - "start_time": "7:30 PM" + "active": 0 }, "41": { - "active": 1, + "start_time": "8:00 PM", "end_time": "9:00 PM", - "start_time": "8:00 PM" + "active": 0 }, "42": { - "active": 1, + "start_time": "8:30 PM", "end_time": "9:30 PM", - "start_time": "8:30 PM" + "active": 0 }, "43": { - "active": 1, + "start_time": "9:00 PM", "end_time": "10:00 PM", - "start_time": "9:00 PM" + "active": 1 }, "44": { - "active": 1, + "start_time": "9:30 PM", "end_time": "10:30 PM", - "start_time": "9:30 PM" + "active": 1 }, "45": { - "active": 1, - "end_time": "11:00 AM", - "start_time": "10:00 PM" + "start_time": "10:00 PM", + "end_time": "11:00 PM", + "active": 1 }, "46": { - "active": 1, + "start_time": "10:30 PM", "end_time": "11:30 PM", - "start_time": "10:30 PM" + "active": 1 }, "47": { - "active": 1, + "start_time": "11:00 PM", "end_time": "12:00 AM", - "start_time": "11:00 PM" + "active": 1 }, "48": { - "active": 1, + "start_time": "11:30 PM", "end_time": "12:30 AM", - "start_time": "11:30 PM" + "active": 0 } - } + }, + "service_type": "electricity" }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/session.json b/tests/components/electric_kiwi/fixtures/session.json new file mode 100644 index 00000000000..ee04aaca549 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session.json @@ -0,0 +1,23 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [ + { + "service": "Electricity", + "identifier": "00000000DDA", + "is_primary_service": true, + "service_status": "Y" + } + ], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/session_no_services.json b/tests/components/electric_kiwi/fixtures/session_no_services.json new file mode 100644 index 00000000000..62ae7aea20a --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session_no_services.json @@ -0,0 +1,16 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 681320972b5..ab643a0ddf1 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -3,70 +3,40 @@ from __future__ import annotations from http import HTTPStatus -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from electrickiwi_api.exceptions import ApiException import pytest -from homeassistant import config_entries -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.electric_kiwi.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, SCOPE_VALUES, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.setup import async_setup_component -from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI +from .conftest import CLIENT_ID, REDIRECT_URI from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -@pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup application credentials component.""" - await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: - """Test config flow base case with no credentials registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "missing_credentials" - - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "electrickiwi_api") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" - await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) - ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, @@ -76,13 +46,13 @@ async def test_full_flow( }, ) - URL_SCOPE = SCOPE_VALUES.replace(" ", "+") + url_scope = SCOPE_VALUES.replace(" ", "+") assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URI}" f"&state={state}" - f"&scope={URL_SCOPE}" + f"&scope={url_scope}" ) client = await hass_client_no_auth() @@ -90,6 +60,7 @@ async def test_full_flow( assert resp.status == HTTPStatus.OK assert resp.headers["content-type"] == "text/html; charset=utf-8" + aioclient_mock.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json={ @@ -106,20 +77,73 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + electrickiwi_api: AsyncMock, +) -> None: + """Check failure on creation of entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + url_scope = SCOPE_VALUES.replace(" ", "+") + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}" + f"&scope={url_scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + electrickiwi_api.get_active_session.side_effect = ApiException() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "connection_error" + + @pytest.mark.usefixtures("current_request_with_host") async def test_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, - config_entry: MockConfigEntry, + migrated_config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - config_entry.add_to_hass(hass) + migrated_config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER, "entry_id": DOMAIN} ) state = config_entry_oauth2_flow._encode_jwt( @@ -145,7 +169,9 @@ async def test_existing_entry( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -154,13 +180,13 @@ async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, - config_entry: MockConfigEntry, - setup_credentials: None, + mock_setup_entry: AsyncMock, + migrated_config_entry: MockConfigEntry, ) -> None: """Test Electric Kiwi reauthentication.""" - config_entry.add_to_hass(hass) - result = await config_entry.start_reauth_flow(hass) + migrated_config_entry.add_to_hass(hass) + + result = await migrated_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -189,8 +215,11 @@ async def test_reauthentication( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" diff --git a/tests/components/electric_kiwi/test_init.py b/tests/components/electric_kiwi/test_init.py new file mode 100644 index 00000000000..947f788ad55 --- /dev/null +++ b/tests/components/electric_kiwi/test_init.py @@ -0,0 +1,135 @@ +"""Test the Electric Kiwi init.""" + +import http +from unittest.mock import AsyncMock, patch + +from aiohttp import RequestInfo +from aiohttp.client_exceptions import ClientResponseError +from electrickiwi_api.exceptions import ApiException, AuthException +import pytest + +from homeassistant.components.electric_kiwi.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + await init_integration(hass, config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_multiple_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, + config_entry2: MockConfigEntry, +) -> None: + """Test a successful setup and unload of multiple entries.""" + + for entry in (config_entry, config_entry2): + await init_integration(hass, entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + for entry in (config_entry, config_entry2): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("status", "expected_state"), + [ + ( + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_refresh_token_validity_failures( + hass: HomeAssistant, + config_entry: MockConfigEntry, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test token refresh failure status.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + RequestInfo("", "POST", {}, ""), None, status=status + ), + ) as mock_async_ensure_token_valid: + await init_integration(hass, config_entry) + mock_async_ensure_token_valid.assert_called_once() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_unique_id_migration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the unique ID is migrated to the customer number.""" + + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, "123456_515363_sensor", config_entry=config_entry + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + new_entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert new_entry.minor_version == 2 + assert new_entry.unique_id == "123456" + entity_entry = entity_registry.async_get( + "sensor.electric_kiwi_123456_515363_sensor" + ) + assert entity_entry.unique_id == "123456_00000000DDA_sensor" + + +async def test_unique_id_migration_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = ApiException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_unique_id_migration_auth_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = AuthException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index a85eb16a986..3e58b33a998 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import dt as dt_util -from .conftest import ComponentSetup, YieldFixture +from . import init_integration from tests.common import MockConfigEntry @@ -47,10 +47,9 @@ def restore_timezone(): async def test_hop_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: Mock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, ) -> None: @@ -61,7 +60,7 @@ async def test_hop_sensors( sensor state should be set to today at 4pm or if now is past 4pm, then tomorrow at 4pm. """ - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -70,8 +69,7 @@ async def test_hop_sensors( state = hass.states.get(sensor) assert state - api = ek_api(Mock()) - hop_data = await api.get_hop() + hop_data = await electrickiwi_api.get_hop() value = _check_and_move_time(hop_data, sensor_state) @@ -98,20 +96,19 @@ async def test_hop_sensors( ), ( "sensor.next_billing_date", - "2020-11-03T00:00:00", + "2025-02-19T00:00:00", SensorDeviceClass.DATE, None, ), - ("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), + ("sensor.hour_of_power_savings", "11.2", None, SensorStateClass.MEASUREMENT), ], ) async def test_account_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: AsyncMock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, device_class: str, @@ -119,7 +116,7 @@ async def test_account_sensors( ) -> None: """Test Account sensors for the Electric Kiwi integration.""" - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -133,9 +130,9 @@ async def test_account_sensors( assert state.attributes.get(ATTR_STATE_CLASS) == state_class -async def test_check_and_move_time(ek_api: AsyncMock) -> None: +async def test_check_and_move_time(electrickiwi_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" - hop = await ek_api(Mock()).get_hop() + hop = await electrickiwi_api.get_hop() test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) dt_util.set_default_time_zone(TEST_TIMEZONE) diff --git a/tests/components/fireservicerota/test_config_flow.py b/tests/components/fireservicerota/test_config_flow.py index 5555a8d649c..8d150034ec9 100644 --- a/tests/components/fireservicerota/test_config_flow.py +++ b/tests/components/fireservicerota/test_config_flow.py @@ -66,7 +66,7 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: """Test that invalid credentials throws an error.""" with patch( - "homeassistant.components.fireservicerota.FireServiceRota.request_tokens", + "homeassistant.components.fireservicerota.coordinator.FireServiceRota.request_tokens", side_effect=InvalidAuthError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/foscam/test_init.py b/tests/components/foscam/test_init.py index 0b82ed3b02a..a7b6a8c8f0b 100644 --- a/tests/components/foscam/test_init.py +++ b/tests/components/foscam/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from homeassistant.components.foscam import DOMAIN, config_flow +from homeassistant.components.foscam.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -18,9 +18,7 @@ async def test_unique_id_new_entry( entity_registry: er.EntityRegistry, ) -> None: """Test unique ID for a newly added device is correct.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID - ) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) entry.add_to_hass(hass) with ( @@ -46,7 +44,7 @@ async def test_switch_unique_id_migration_ok( ) -> None: """Test that the unique ID for a sleep switch is migrated to the new format.""" entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1 + domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=1 ) entry.add_to_hass(hass) @@ -57,7 +55,7 @@ async def test_switch_unique_id_migration_ok( # Update config entry with version 2 entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2 + domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID, version=2 ) entry.add_to_hass(hass) @@ -84,9 +82,7 @@ async def test_unique_id_migration_not_needed( entity_registry: er.EntityRegistry, ) -> None: """Test that the unique ID for a sleep switch is not executed if already in right format.""" - entry = MockConfigEntry( - domain=config_flow.DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID - ) + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG, entry_id=ENTRY_ID) entry.add_to_hass(hass) entity_registry.async_get_or_create( diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 9e1ec00b52e..2f3df3eed7f 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -140,7 +140,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'HA folder ID', ]), @@ -211,7 +211,7 @@ tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', - 'name': 'Test_-_2025-01-01_01.23_45678000.tar', + 'name': 'Test_2025-01-01_01.23_45678000.tar', 'parents': list([ 'new folder id', ]), diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 21458abb7c8..c89981e67bb 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -244,7 +244,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -296,7 +296,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -348,7 +348,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -401,7 +401,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -454,7 +454,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', @@ -565,7 +565,7 @@ 'top_k': 64, 'top_p': 0.95, }), - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', 'safety_settings': dict({ 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index 316bf74b72a..b445499ad49 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-1.5-flash-latest', + 'chat_model': 'models/gemini-2.0-flash', 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f68f4c6bf14..c9e02a6d009 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', }), ), tuple( @@ -32,7 +32,7 @@ tuple( ), dict({ - 'model_name': 'models/gemini-1.5-flash-latest', + 'model_name': 'models/gemini-2.0-flash', }), ), tuple( diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index d4992c732e1..ee5291196c3 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -39,6 +39,12 @@ from tests.common import MockConfigEntry @pytest.fixture def mock_models(): """Mock the model list API.""" + model_20_flash = Mock( + display_name="Gemini 2.0 Flash", + supported_generation_methods=["generateContent"], + ) + model_20_flash.name = "models/gemini-2.0-flash" + model_15_flash = Mock( display_name="Gemini 1.5 Flash", supported_generation_methods=["generateContent"], @@ -58,7 +64,7 @@ def mock_models(): model_10_pro.name = "models/gemini-pro" with patch( "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", - return_value=iter([model_15_flash, model_15_pro, model_10_pro]), + return_value=iter([model_20_flash, model_15_flash, model_15_pro, model_10_pro]), ): yield diff --git a/tests/components/govee_light_local/conftest.py b/tests/components/govee_light_local/conftest.py index 6a8ee99b764..61a6394bd6a 100644 --- a/tests/components/govee_light_local/conftest.py +++ b/tests/components/govee_light_local/conftest.py @@ -4,7 +4,8 @@ from asyncio import Event from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from govee_local_api import GoveeLightCapability +from govee_local_api import GoveeLightCapabilities +from govee_local_api.light_capabilities import COMMON_FEATURES import pytest from homeassistant.components.govee_light_local.coordinator import GoveeController @@ -34,8 +35,6 @@ def fixture_mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -DEFAULT_CAPABILITEIS: set[GoveeLightCapability] = { - GoveeLightCapability.COLOR_RGB, - GoveeLightCapability.COLOR_KELVIN_TEMPERATURE, - GoveeLightCapability.BRIGHTNESS, -} +DEFAULT_CAPABILITIES: GoveeLightCapabilities = GoveeLightCapabilities( + features=COMMON_FEATURES, segments=[], scenes={} +) diff --git a/tests/components/govee_light_local/test_config_flow.py b/tests/components/govee_light_local/test_config_flow.py index 2e7144fae3a..103159f1a2b 100644 --- a/tests/components/govee_light_local/test_config_flow.py +++ b/tests/components/govee_light_local/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.govee_light_local.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: @@ -20,7 +20,7 @@ def _get_devices(mock_govee_api: AsyncMock) -> list[GoveeDevice]: ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] diff --git a/tests/components/govee_light_local/test_light.py b/tests/components/govee_light_local/test_light.py index 4a1125643fa..24bdbba9e11 100644 --- a/tests/components/govee_light_local/test_light.py +++ b/tests/components/govee_light_local/test_light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import DEFAULT_CAPABILITEIS +from .conftest import DEFAULT_CAPABILITIES from tests.common import MockConfigEntry @@ -26,7 +26,7 @@ async def test_light_known_device( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -96,7 +96,7 @@ async def test_light_remove(hass: HomeAssistant, mock_govee_api: AsyncMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd1", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -152,7 +152,7 @@ async def test_light_setup_retry_eaddrinuse( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -180,7 +180,7 @@ async def test_light_setup_error( ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -204,7 +204,7 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -260,7 +260,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] @@ -335,7 +335,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No ip="192.168.1.100", fingerprint="asdawdqwdqwd", sku="H615A", - capabilities=DEFAULT_CAPABILITEIS, + capabilities=DEFAULT_CAPABILITIES, ) ] diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index 1f3a14fade1..2fe3513a646 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -8,7 +8,6 @@ 'habitica_data': dict({ 'tasks': list([ dict({ - 'Type': 'habit', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -71,6 +70,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'habit', 'up': True, 'updatedAt': '2024-10-10T15:57:14.287000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -80,7 +80,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'todo', 'alias': None, 'attribute': 'str', 'byHabitica': True, @@ -143,6 +142,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'todo', 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -152,7 +152,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'reward', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -215,6 +214,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'reward', 'up': None, 'updatedAt': '2024-10-10T15:57:14.290000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -224,7 +224,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'daily', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -341,6 +340,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'daily', 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index f40d50ded98..e25ed8db313 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -3,9 +3,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -20,13 +19,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -44,12 +43,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -66,18 +65,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -92,13 +91,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -117,19 +116,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -146,18 +145,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -172,13 +171,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -196,12 +195,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -218,18 +217,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -244,13 +243,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -269,19 +268,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -298,18 +297,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -321,7 +320,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -329,13 +328,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -354,7 +353,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -362,7 +361,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -370,7 +369,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -378,7 +377,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -386,7 +385,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -394,7 +393,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -402,7 +401,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -410,7 +409,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -418,7 +417,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -426,7 +425,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -434,25 +433,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -464,23 +463,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -495,13 +494,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -520,7 +519,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -528,7 +527,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -536,7 +535,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -544,7 +543,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -552,7 +551,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -560,7 +559,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -568,7 +567,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -576,7 +575,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -584,7 +583,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -592,30 +591,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -627,23 +626,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -655,7 +654,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -663,13 +662,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -687,18 +686,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -710,24 +709,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -742,8 +741,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -766,12 +765,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -786,22 +785,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -816,8 +815,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -840,17 +839,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -867,18 +866,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -893,7 +892,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -917,12 +916,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -939,18 +938,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -965,8 +964,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -989,12 +988,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1009,21 +1008,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1038,7 +1037,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1062,12 +1061,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1084,18 +1083,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1110,13 +1109,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1134,18 +1133,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1157,14 +1156,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -1172,9 +1172,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1189,13 +1188,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1213,18 +1212,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1236,23 +1235,23 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1267,7 +1266,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1291,12 +1290,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -1311,21 +1310,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1340,7 +1339,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1364,12 +1363,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1384,12 +1383,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -1402,9 +1402,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1416,7 +1415,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1424,13 +1423,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1449,7 +1448,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1457,7 +1456,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1465,7 +1464,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1473,7 +1472,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1481,7 +1480,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1489,7 +1488,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1497,7 +1496,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1505,7 +1504,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1513,7 +1512,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1521,7 +1520,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1529,25 +1528,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1559,14 +1558,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -1579,9 +1579,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1596,13 +1595,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1620,12 +1619,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1642,18 +1641,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1668,13 +1667,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1693,19 +1692,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1722,9 +1721,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1737,9 +1737,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1751,7 +1750,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1759,13 +1758,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1783,18 +1782,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1806,24 +1805,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1838,8 +1837,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -1862,12 +1861,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1882,13 +1881,14 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1901,9 +1901,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1918,13 +1917,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1943,7 +1942,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1951,7 +1950,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1959,7 +1958,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1967,7 +1966,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1975,7 +1974,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1983,7 +1982,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1991,7 +1990,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1999,7 +1998,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2007,7 +2006,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2015,30 +2014,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -2050,14 +2049,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), @@ -2070,9 +2070,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2087,13 +2086,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2112,19 +2111,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2141,18 +2140,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2164,7 +2163,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2172,13 +2171,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2197,7 +2196,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2205,7 +2204,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2213,7 +2212,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2221,7 +2220,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2229,7 +2228,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2237,7 +2236,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2245,7 +2244,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2253,7 +2252,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2261,7 +2260,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2269,7 +2268,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2277,25 +2276,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2307,14 +2306,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -2327,9 +2327,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2344,13 +2343,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2368,12 +2367,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2390,18 +2389,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2416,13 +2415,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2441,19 +2440,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2470,18 +2469,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2496,13 +2495,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2520,12 +2519,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2542,18 +2541,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2568,13 +2567,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2593,19 +2592,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2622,18 +2621,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2645,7 +2644,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2653,13 +2652,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2678,7 +2677,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2686,7 +2685,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2694,7 +2693,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2702,7 +2701,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2710,7 +2709,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2718,7 +2717,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2726,7 +2725,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2734,7 +2733,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2742,7 +2741,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2750,7 +2749,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2758,25 +2757,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2788,23 +2787,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2819,13 +2818,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2844,7 +2843,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2852,7 +2851,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2860,7 +2859,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2868,7 +2867,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2876,7 +2875,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2884,7 +2883,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2892,7 +2891,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2900,7 +2899,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2908,7 +2907,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2916,30 +2915,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -2951,23 +2950,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2982,8 +2981,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3006,12 +3005,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3026,22 +3025,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3056,8 +3055,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3080,17 +3079,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -3107,18 +3106,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3133,7 +3132,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3157,12 +3156,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3179,18 +3178,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3205,8 +3204,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3229,12 +3228,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3249,21 +3248,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3278,7 +3277,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3302,12 +3301,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3324,18 +3323,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3350,13 +3349,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3374,18 +3373,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3397,14 +3396,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -3412,9 +3412,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3429,13 +3428,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3453,18 +3452,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3476,14 +3475,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -3502,9 +3502,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3516,7 +3515,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -3524,13 +3523,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3548,18 +3547,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3571,24 +3570,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3603,7 +3602,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3627,12 +3626,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3647,12 +3646,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3665,9 +3665,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3682,7 +3681,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3706,12 +3705,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -3726,12 +3725,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3744,9 +3744,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3761,13 +3760,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3785,12 +3784,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3807,18 +3806,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3833,13 +3832,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3858,19 +3857,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3887,18 +3886,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3913,13 +3912,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3937,12 +3936,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3959,18 +3958,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3985,13 +3984,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4010,19 +4009,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4039,18 +4038,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4062,7 +4061,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4070,13 +4069,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4095,7 +4094,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4103,7 +4102,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4111,7 +4110,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4119,7 +4118,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4127,7 +4126,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4135,7 +4134,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4143,7 +4142,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4151,7 +4150,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4159,7 +4158,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4167,7 +4166,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4175,25 +4174,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4205,23 +4204,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4236,13 +4235,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4261,7 +4260,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4269,7 +4268,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4277,7 +4276,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4285,7 +4284,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4293,7 +4292,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4301,7 +4300,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4309,7 +4308,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4317,7 +4316,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4325,7 +4324,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4333,30 +4332,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4368,23 +4367,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4396,7 +4395,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4404,13 +4403,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4428,18 +4427,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -4451,24 +4450,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4483,13 +4482,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4507,18 +4506,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4530,14 +4529,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -4545,9 +4545,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4562,13 +4561,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4586,18 +4585,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4609,14 +4608,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -4629,9 +4629,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4643,7 +4642,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4651,13 +4650,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4676,7 +4675,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4684,7 +4683,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4692,7 +4691,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4700,7 +4699,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4708,7 +4707,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4716,7 +4715,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4724,7 +4723,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4732,7 +4731,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4740,7 +4739,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4748,7 +4747,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4756,25 +4755,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4786,23 +4785,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4817,13 +4816,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4842,7 +4841,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4850,7 +4849,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4858,7 +4857,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4866,7 +4865,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4874,7 +4873,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4882,7 +4881,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4890,7 +4889,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4898,7 +4897,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4906,7 +4905,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4914,30 +4913,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4949,23 +4948,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4977,7 +4976,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4985,13 +4984,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5009,18 +5008,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -5032,24 +5031,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5064,13 +5063,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5088,18 +5087,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5111,14 +5110,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -5126,9 +5126,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5143,13 +5142,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5167,18 +5166,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5190,14 +5189,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -5210,9 +5210,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5227,13 +5226,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5251,12 +5250,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5273,18 +5272,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5299,13 +5298,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5324,19 +5323,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5353,18 +5352,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5379,13 +5378,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5403,12 +5402,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5425,18 +5424,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5451,13 +5450,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5476,19 +5475,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5505,9 +5504,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -5520,9 +5520,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5537,7 +5536,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5561,12 +5560,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5583,9 +5582,10 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), @@ -5598,9 +5598,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5615,8 +5614,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5639,12 +5638,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5659,22 +5658,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5689,8 +5688,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5713,17 +5712,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -5740,18 +5739,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5766,7 +5765,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5790,12 +5789,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5812,18 +5811,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5838,8 +5837,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5862,12 +5861,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5882,21 +5881,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5911,7 +5910,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5935,12 +5934,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5957,18 +5956,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5983,7 +5982,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6007,12 +6006,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -6027,21 +6026,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6056,7 +6055,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6080,12 +6079,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -6100,12 +6099,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -6118,9 +6118,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6135,8 +6134,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6159,12 +6158,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6179,22 +6178,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6209,8 +6208,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6233,17 +6232,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -6260,18 +6259,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6286,7 +6285,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6310,12 +6309,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6332,18 +6331,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6358,8 +6357,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6382,12 +6381,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6402,21 +6401,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6431,7 +6430,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6455,12 +6454,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -6475,21 +6474,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6504,7 +6503,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6528,12 +6527,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -6548,12 +6547,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 496dc93df32..0dd2adc99ed 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -26,6 +26,7 @@ from aiohasupervisor.models import ( jobs as supervisor_jobs, mounts as supervisor_mounts, ) +from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest @@ -39,11 +40,7 @@ from homeassistant.components.backup import ( Folder, ) from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.backup import ( - LOCATION_CLOUD_BACKUP, - LOCATION_LOCAL, - RESTORE_JOB_ID_ENV, -) +from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -60,17 +57,12 @@ TEST_BACKUP = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -89,14 +81,9 @@ TEST_BACKUP_DETAILS = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP.location, location_attributes=TEST_BACKUP.location_attributes, - locations=TEST_BACKUP.locations, name=TEST_BACKUP.name, - protected=TEST_BACKUP.protected, repositories=[], - size=TEST_BACKUP.size, - size_bytes=TEST_BACKUP.size_bytes, slug=TEST_BACKUP.slug, supervisor_version="2024.11.2", type=TEST_BACKUP.type, @@ -110,17 +97,12 @@ TEST_BACKUP_2 = supervisor_backups.Backup( homeassistant=False, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -139,14 +121,9 @@ TEST_BACKUP_DETAILS_2 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_2.location, location_attributes=TEST_BACKUP_2.location_attributes, - locations=TEST_BACKUP_2.locations, name=TEST_BACKUP_2.name, - protected=TEST_BACKUP_2.protected, repositories=[], - size=TEST_BACKUP_2.size, - size_bytes=TEST_BACKUP_2.size_bytes, slug=TEST_BACKUP_2.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_2.type, @@ -160,17 +137,12 @@ TEST_BACKUP_3 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location="share", location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={"share"}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -189,14 +161,9 @@ TEST_BACKUP_DETAILS_3 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant=None, - location=TEST_BACKUP_3.location, location_attributes=TEST_BACKUP_3.location_attributes, - locations=TEST_BACKUP_3.locations, name=TEST_BACKUP_3.name, - protected=TEST_BACKUP_3.protected, repositories=[], - size=TEST_BACKUP_3.size, - size_bytes=TEST_BACKUP_3.size_bytes, slug=TEST_BACKUP_3.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_3.type, @@ -211,17 +178,12 @@ TEST_BACKUP_4 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=None, location_attributes={ - LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + LOCATION_LOCAL_STORAGE: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={None}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -240,14 +202,9 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=True, homeassistant="2024.12.0", - location=TEST_BACKUP_4.location, location_attributes=TEST_BACKUP_4.location_attributes, - locations=TEST_BACKUP_4.locations, name=TEST_BACKUP_4.name, - protected=TEST_BACKUP_4.protected, repositories=[], - size=TEST_BACKUP_4.size, - size_bytes=TEST_BACKUP_4.size_bytes, slug=TEST_BACKUP_4.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_4.type, @@ -261,17 +218,12 @@ TEST_BACKUP_5 = supervisor_backups.Backup( homeassistant=True, ), date=datetime.fromisoformat("1970-01-01T00:00:00Z"), - location=LOCATION_CLOUD_BACKUP, location_attributes={ LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes( protected=False, size_bytes=1048576 ) }, - locations={LOCATION_CLOUD_BACKUP}, name="Test", - protected=False, - size=1.0, - size_bytes=1048576, slug="abc123", type=supervisor_backups.BackupType.PARTIAL, ) @@ -290,14 +242,9 @@ TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete( folders=[supervisor_backups.Folder.SHARE], homeassistant_exclude_database=False, homeassistant="2024.12.0", - location=TEST_BACKUP_5.location, location_attributes=TEST_BACKUP_5.location_attributes, - locations=TEST_BACKUP_5.locations, name=TEST_BACKUP_5.name, - protected=TEST_BACKUP_5.protected, repositories=[], - size=TEST_BACKUP_5.size, - size_bytes=TEST_BACKUP_5.size_bytes, slug=TEST_BACKUP_5.slug, supervisor_version="2024.11.2", type=TEST_BACKUP_5.type, @@ -312,6 +259,7 @@ TEST_JOB_NOT_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=False, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_JOB_DONE = supervisor_jobs.Job( @@ -322,6 +270,7 @@ TEST_JOB_DONE = supervisor_jobs.Job( stage="copy_additional_locations", done=True, errors=[], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( @@ -340,6 +289,7 @@ TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( ), ) ], + created=datetime.fromisoformat("1970-01-01T00:00:00Z"), child_jobs=[], ) @@ -580,7 +530,10 @@ async def test_agent_download( assert await resp.content.read() == b"backup data" supervisor_client.backups.download_backup.assert_called_once_with( - "abc123", options=supervisor_backups.DownloadBackupOptions(location=None) + "abc123", + options=supervisor_backups.DownloadBackupOptions( + location=LOCATION_LOCAL_STORAGE + ), ) @@ -766,7 +719,10 @@ async def test_agent_delete_backup( assert response["success"] assert response["result"] == {"agent_errors": {}} supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -812,7 +768,10 @@ async def test_agent_delete_with_error( assert response == {"id": 1, "type": "result"} | expected_response supervisor_client.backups.remove_backup.assert_called_once_with( - backup_id, options=supervisor_backups.RemoveBackupOptions(location={None}) + backup_id, + options=supervisor_backups.RemoveBackupOptions( + location={LOCATION_LOCAL_STORAGE} + ), ) @@ -887,11 +846,11 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions( "supervisor.backup_request_date": "2025-01-30T05:42:12.345678-08:00", "with_automatic_settings": False, }, - filename=PurePath("Test_-_2025-01-30_05.42_12345678.tar"), + filename=PurePath("Test_2025-01-30_05.42_12345678.tar"), folders={"ssl"}, homeassistant_exclude_database=False, homeassistant=True, - location=[None], + location=[LOCATION_LOCAL_STORAGE], name="Test", password=None, ) @@ -947,7 +906,7 @@ async def test_reader_writer_create( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1022,7 +981,7 @@ async def test_reader_writer_create_report_progress( """Test generating a backup.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1129,7 +1088,7 @@ async def test_reader_writer_create_job_done( """Test generating a backup, and backup job finishes early.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE @@ -1198,7 +1157,7 @@ async def test_reader_writer_create_job_done( None, ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], False, [], ), @@ -1207,7 +1166,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], "hunter2", - [None, "share1", "share2", "share3"], + [LOCATION_LOCAL_STORAGE, "share1", "share2", "share3"], True, [], ), @@ -1225,7 +1184,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share1", "share2", "share3"], True, - [None], + [LOCATION_LOCAL_STORAGE], ), ( [ @@ -1242,7 +1201,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["share2", "share3"], True, - [None, "share1"], + [LOCATION_LOCAL_STORAGE, "share1"], ), ( [ @@ -1258,7 +1217,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"], None, - [None, "share1", "share2"], + [LOCATION_LOCAL_STORAGE, "share1", "share2"], True, ["share3"], ), @@ -1274,7 +1233,7 @@ async def test_reader_writer_create_job_done( "hunter2", ["hassio.local"], None, - [None], + [LOCATION_LOCAL_STORAGE], False, [], ), @@ -1312,15 +1271,14 @@ async def test_reader_writer_create_per_agent_encryption( for i in range(1, 4) ], ) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = replace( TEST_BACKUP_DETAILS, extra=DEFAULT_BACKUP_OPTIONS.extra, - locations=create_locations, location_attributes={ - location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes( + location: supervisor_backups.BackupLocationAttributes( protected=create_protected, - size_bytes=TEST_BACKUP_DETAILS.size_bytes, + size_bytes=1048576, ) for location in create_locations }, @@ -1400,7 +1358,7 @@ async def test_reader_writer_create_per_agent_encryption( upload_locations ) for call in supervisor_client.backups.upload_backup.mock_calls: - assert call.args[1].filename == PurePath("Test_-_2025-01-30_05.42_12345678.tar") + assert call.args[1].filename == PurePath("Test_2025-01-30_05.42_12345678.tar") upload_call_locations: set = call.args[1].location assert len(upload_call_locations) == 1 assert upload_call_locations.pop() in upload_locations @@ -1514,7 +1472,7 @@ async def test_reader_writer_create_missing_reference_error( ) -> None: """Test missing reference error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1581,7 +1539,7 @@ async def test_reader_writer_create_download_remove_error( ) -> None: """Test download and remove error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE method_mock = getattr(supervisor_client.backups, method) @@ -1668,7 +1626,7 @@ async def test_reader_writer_create_info_error( ) -> None: """Test backup info error when generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1745,7 +1703,7 @@ async def test_reader_writer_create_remote_backup( """Test generating a backup which will be uploaded to a remote agent.""" client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -1848,7 +1806,7 @@ async def test_reader_writer_create_wrong_parameters( ) -> None: """Test generating a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_backup.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS await client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1975,7 +1933,7 @@ async def test_reader_writer_restore( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = get_job_result @@ -2006,7 +1964,7 @@ async def test_reader_writer_restore( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2040,7 +1998,7 @@ async def test_reader_writer_restore_report_progress( ) -> None: """Test restoring a backup.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2071,7 +2029,7 @@ async def test_reader_writer_restore_report_progress( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2193,7 +2151,7 @@ async def test_reader_writer_restore_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) @@ -2221,7 +2179,7 @@ async def test_reader_writer_restore_late_error( ) -> None: """Test restoring a backup with error.""" client = await hass_ws_client(hass) - supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) supervisor_client.backups.list.return_value = [TEST_BACKUP] supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE @@ -2250,7 +2208,7 @@ async def test_reader_writer_restore_late_error( background=True, folders=None, homeassistant=True, - location=None, + location=LOCATION_LOCAL_STORAGE, password=None, ), ) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 145087073af..3696ea66c03 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -106,7 +107,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): @pytest.fixture(autouse=True) -def mock_test_firmware_platform( +async def mock_test_firmware_platform( hass: HomeAssistant, ) -> Generator[None]: """Fixture for a test config flow.""" @@ -116,6 +117,8 @@ def mock_test_firmware_platform( mock_integration(hass, mock_module) mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + await async_setup_component(hass, "homeassistant_hardware", {}) + with mock_config_flow(TEST_DOMAIN, FakeFirmwareConfigFlow): yield @@ -189,6 +192,10 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", return_value=mock_otbr_manager, ), + patch( + "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", + return_value=mock_otbr_manager, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager", return_value=mock_flasher_manager, @@ -197,6 +204,10 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", return_value=is_hassio, ), + patch( + "homeassistant.components.homeassistant_hardware.util.is_hassio", + return_value=is_hassio, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type", return_value=app_type, diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index f5375fb51dd..c240d0198ca 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -1,6 +1,6 @@ """Test the Home Assistant hardware firmware config flow failure cases.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -9,7 +9,11 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) -from homeassistant.components.homeassistant_hardware.util import ApplicationType +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -548,21 +552,28 @@ async def test_options_flow_zigbee_to_thread_zha_configured( assert await hass.config_entries.async_setup(config_entry.entry_id) - # Set up ZHA as well - zha_config_entry = MockConfigEntry( - domain="zha", - data={"device": {"path": TEST_DEVICE}}, - ) - zha_config_entry.add_to_hass(hass) + # Pretend ZHA is using the stick + with patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.guess_hardware_owners", + return_value=[ + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="zha", + owners=[OwningIntegration(config_entry_id="some_config_entry_id")], + ) + ], + ): + # Confirm options flow + result = await hass.config_entries.options.async_init(config_entry.entry_id) - # Confirm options flow - result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Pick Thread + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, + ) - # Pick Thread - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "zha_still_using_stick" diff --git a/tests/components/homeassistant_hardware/test_helpers.py b/tests/components/homeassistant_hardware/test_helpers.py new file mode 100644 index 00000000000..183995be7ce --- /dev/null +++ b/tests/components/homeassistant_hardware/test_helpers.py @@ -0,0 +1,185 @@ +"""Test hardware helpers.""" + +import logging +from unittest.mock import AsyncMock, MagicMock, Mock, call + +import pytest + +from homeassistant.components.homeassistant_hardware.const import DATA_COMPONENT +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_callback, + async_register_firmware_info_provider, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +FIRMWARE_INFO_EZSP = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], +) + +FIRMWARE_INFO_SPINEL = FirmwareInfo( + device="/dev/serial/by-id/device2", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], +) + + +async def test_dispatcher_registration(hass: HomeAssistant) -> None: + """Test HardwareInfoDispatcher registration.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + + # Mock provider 1 with a synchronous method to pull firmware info + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + # Mock provider 2 with an asynchronous method to pull firmware info + provider2_config_entry = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id2", + data={}, + ) + provider2_config_entry.add_to_hass(hass) + provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider2_firmware = MagicMock(spec=["async_get_firmware_info"]) + provider2_firmware.async_get_firmware_info = AsyncMock( + return_value=FIRMWARE_INFO_SPINEL + ) + async_register_firmware_info_provider(hass, "otbr", provider2_firmware) + + # Double registration won't work + with pytest.raises(ValueError, match="Domain zha is already registered"): + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + # We can iterate over the results + info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()] + assert info == [ + FIRMWARE_INFO_EZSP, + FIRMWARE_INFO_SPINEL, + ] + + callback1 = Mock() + cancel1 = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device1", callback1 + ) + + callback2 = Mock() + cancel2 = async_register_firmware_info_callback( + hass, "/dev/serial/by-id/device2", callback2 + ) + + # And receive notification callbacks + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL) + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + cancel1() + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + await async_notify_firmware_info(hass, "otbr", firmware_info=FIRMWARE_INFO_SPINEL) + cancel2() + + assert callback1.mock_calls == [ + call(FIRMWARE_INFO_EZSP), + call(FIRMWARE_INFO_EZSP), + ] + + assert callback2.mock_calls == [ + call(FIRMWARE_INFO_SPINEL), + call(FIRMWARE_INFO_SPINEL), + ] + + +async def test_dispatcher_iter_error_handling( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test HardwareInfoDispatcher ignoring errors from firmware info providers.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(side_effect=Exception("Boom!")) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + provider2_config_entry = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id2", + data={}, + ) + provider2_config_entry.add_to_hass(hass) + provider2_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider2_firmware = MagicMock(spec=["async_get_firmware_info"]) + provider2_firmware.async_get_firmware_info = AsyncMock( + return_value=FIRMWARE_INFO_SPINEL + ) + async_register_firmware_info_provider(hass, "otbr", provider2_firmware) + + with caplog.at_level(logging.ERROR): + info = [i async for i in hass.data[DATA_COMPONENT].iter_firmware_info()] + + assert info == [FIRMWARE_INFO_SPINEL] + assert "Error while getting firmware info from" in caplog.text + + +async def test_dispatcher_callback_error_handling( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test HardwareInfoDispatcher ignoring errors from firmware info callbacks.""" + + await async_setup_component(hass, "homeassistant_hardware", {}) + provider1_config_entry = MockConfigEntry( + domain="zha", + unique_id="some_unique_id1", + data={}, + ) + provider1_config_entry.add_to_hass(hass) + provider1_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + provider1_firmware = MagicMock(spec=["get_firmware_info"]) + provider1_firmware.get_firmware_info = MagicMock(return_value=FIRMWARE_INFO_EZSP) + async_register_firmware_info_provider(hass, "zha", provider1_firmware) + + callback1 = Mock(side_effect=Exception("Some error")) + async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback1) + + callback2 = Mock() + async_register_firmware_info_callback(hass, "/dev/serial/by-id/device1", callback2) + + with caplog.at_level(logging.ERROR): + await async_notify_firmware_info(hass, "zha", firmware_info=FIRMWARE_INFO_EZSP) + + assert "Error while notifying firmware info listener" in caplog.text + + assert callback1.mock_calls == [call(FIRMWARE_INFO_EZSP)] + assert callback2.mock_calls == [call(FIRMWARE_INFO_EZSP)] diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 3f019a0409c..047de3e452c 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -1,18 +1,21 @@ """Test hardware utilities.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_provider, +) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, - FlasherApplicationType, - get_zha_device_path, - guess_firmware_type, - probe_silabs_firmware_type, + FirmwareInfo, + OwningAddon, + OwningIntegration, + guess_firmware_info, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -21,7 +24,21 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( unique_id="some_unique_id", data={ "device": { - "path": "socket://1.2.3.4:5678", + "path": "/dev/ttyUSB1", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, + version=4, +) + +ZHA_CONFIG_ENTRY2 = MockConfigEntry( + domain="zha", + unique_id="some_other_unique_id", + data={ + "device": { + "path": "/dev/ttyUSB2", "baudrate": 115200, "flow_control": None, }, @@ -31,153 +48,202 @@ ZHA_CONFIG_ENTRY = MockConfigEntry( ) -def test_get_zha_device_path() -> None: - """Test extracting the ZHA device path from its config entry.""" - assert ( - get_zha_device_path(ZHA_CONFIG_ENTRY) == ZHA_CONFIG_ENTRY.data["device"]["path"] - ) - - -def test_get_zha_device_path_ignored_discovery() -> None: - """Test extracting the ZHA device path from an ignored ZHA discovery.""" - config_entry = MockConfigEntry( - domain="zha", - unique_id="some_unique_id", - data={}, - version=4, - ) - - assert get_zha_device_path(config_entry) is None - - -async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None: +async def test_guess_firmware_info_unknown(hass: HomeAssistant) -> None: """Test guessing the firmware type.""" - assert (await guess_firmware_type(hass, "/dev/missing")) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="unknown" + await async_setup_component(hass, "homeassistant_hardware", {}) + + assert (await guess_firmware_info(hass, "/dev/missing")) == FirmwareInfo( + device="/dev/missing", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[], ) -async def test_guess_firmware_type(hass: HomeAssistant) -> None: - """Test guessing the firmware.""" - path = ZHA_CONFIG_ENTRY.data["device"]["path"] +async def test_guess_firmware_info_integrations(hass: HomeAssistant) -> None: + """Test guessing the firmware via OTBR and ZHA.""" - ZHA_CONFIG_ENTRY.add_to_hass(hass) + await async_setup_component(hass, "homeassistant_hardware", {}) - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.NOT_LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=False, firmware_type=ApplicationType.EZSP, source="zha" + # One instance of ZHA and two OTBRs + zha = MockConfigEntry(domain="zha", unique_id="some_unique_id_1") + zha.add_to_hass(hass) + + otbr1 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_2") + otbr1.add_to_hass(hass) + + otbr2 = MockConfigEntry(domain="otbr", unique_id="some_unique_id_3") + otbr2.add_to_hass(hass) + + # First ZHA is running with the stick + zha_firmware_info = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[AsyncMock(is_running=AsyncMock(return_value=True))], ) - # When ZHA is running, we indicate as such when guessing - ZHA_CONFIG_ENTRY.mock_state(hass, ConfigEntryState.LOADED) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" + # First OTBR: neither the addon or the integration are loaded + otbr_firmware_info1 = FirmwareInfo( + device="/dev/serial/by-id/device1", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + AsyncMock(is_running=AsyncMock(return_value=False)), + AsyncMock(is_running=AsyncMock(return_value=False)), + ], ) - mock_otbr_addon_manager = AsyncMock() - mock_multipan_addon_manager = AsyncMock() + # Second OTBR: fully running but is with an unrelated device + otbr_firmware_info2 = FirmwareInfo( + device="/dev/serial/by-id/device2", # An unrelated device + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + AsyncMock(is_running=AsyncMock(return_value=True)), + AsyncMock(is_running=AsyncMock(return_value=True)), + ], + ) - with ( - patch( - "homeassistant.components.homeassistant_hardware.util.is_hassio", - return_value=True, - ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", - return_value=mock_otbr_addon_manager, - ), - patch( - "homeassistant.components.homeassistant_hardware.util.get_multiprotocol_addon_manager", - return_value=mock_multipan_addon_manager, - ), - ): - mock_otbr_addon_manager.async_get_addon_info.side_effect = AddonError() - mock_multipan_addon_manager.async_get_addon_info.side_effect = AddonError() + mock_zha_hardware_info = MagicMock(spec=["get_firmware_info"]) + mock_zha_hardware_info.get_firmware_info = MagicMock(return_value=zha_firmware_info) + async_register_firmware_info_provider(hass, "zha", mock_zha_hardware_info) - # Hassio errors are ignored and we still go with ZHA - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) + async def mock_otbr_async_get_firmware_info( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> FirmwareInfo | None: + return { + otbr1.entry_id: otbr_firmware_info1, + otbr2.entry_id: otbr_firmware_info2, + }.get(config_entry.entry_id) - mock_otbr_addon_manager.async_get_addon_info.side_effect = None - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": "/some/other/device"}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) + mock_otbr_hardware_info = MagicMock(spec=["async_get_firmware_info"]) + mock_otbr_hardware_info.async_get_firmware_info = AsyncMock( + side_effect=mock_otbr_async_get_firmware_info + ) + async_register_firmware_info_provider(hass, "otbr", mock_otbr_hardware_info) - # We will prefer ZHA, as it is running (and actually pointing to the device) - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) + # ZHA wins for the first stick, since it's actually running + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device1") + ) == zha_firmware_info - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.0.0", - ) + # Second stick is communicating exclusively with the second OTBR + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device2") + ) == otbr_firmware_info2 - # We will still prefer ZHA, as it is the one actually running - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.EZSP, source="zha" - ) - - mock_otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Finally, ZHA loses out to OTBR - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.SPINEL, source="otbr" - ) - - mock_multipan_addon_manager.async_get_addon_info.side_effect = None - mock_multipan_addon_manager.async_get_addon_info.return_value = AddonInfo( - available=True, - hostname=None, - options={"device": path}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ) - - # Which will lose out to multi-PAN - assert (await guess_firmware_type(hass, path)) == FirmwareGuess( - is_running=True, firmware_type=ApplicationType.CPC, source="multiprotocol" - ) + # If we stop ZHA, OTBR will take priority + zha_firmware_info.owners[0].is_running.return_value = False + otbr_firmware_info1.owners[0].is_running.return_value = True + assert ( + await guess_firmware_info(hass, "/dev/serial/by-id/device1") + ) == otbr_firmware_info1 -async def test_probe_silabs_firmware_type() -> None: - """Test probing Silabs firmware type.""" +async def test_owning_addon(hass: HomeAssistant) -> None: + """Test `OwningAddon`.""" + owning_addon = OwningAddon(slug="some-addon-slug") + + # Explicitly running with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=RuntimeError, - ): - assert (await probe_silabs_firmware_type("/dev/ttyUSB0")) is None - - with patch( - "homeassistant.components.homeassistant_hardware.util.Flasher.probe_app_type", - side_effect=lambda self: setattr(self, "app_type", FlasherApplicationType.EZSP), - autospec=True, - ) as mock_probe_app_type: - # The application type constant is converted back and forth transparently - result = await probe_silabs_firmware_type( - "/dev/ttyUSB0", probe_methods=[ApplicationType.EZSP] + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) ) - assert result is ApplicationType.EZSP + assert (await owning_addon.is_running(hass)) is True - flasher = mock_probe_app_type.mock_calls[0].args[0] - assert flasher._probe_methods == [FlasherApplicationType.EZSP] + # Explicitly not running + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + return_value=AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + ) + assert (await owning_addon.is_running(hass)) is False + + # Failed to get status + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager" + ) as mock_manager: + mock_manager.return_value.async_get_addon_info = AsyncMock( + side_effect=AddonError() + ) + assert (await owning_addon.is_running(hass)) is False + + +async def test_owning_integration(hass: HomeAssistant) -> None: + """Test `OwningIntegration`.""" + config_entry = MockConfigEntry(domain="mock_domain", unique_id="some_unique_id") + config_entry.add_to_hass(hass) + + owning_integration = OwningIntegration(config_entry_id=config_entry.entry_id) + + # Explicitly running + config_entry.mock_state(hass, ConfigEntryState.LOADED) + assert (await owning_integration.is_running(hass)) is True + + # Explicitly not running + config_entry.mock_state(hass, ConfigEntryState.NOT_LOADED) + assert (await owning_integration.is_running(hass)) is False + + # Missing config entry + owning_integration2 = OwningIntegration(config_entry_id="some_nonexistenct_id") + assert (await owning_integration2.is_running(hass)) is False + + +async def test_firmware_info(hass: HomeAssistant) -> None: + """Test `FirmwareInfo`.""" + + owner1 = AsyncMock() + owner2 = AsyncMock() + + firmware_info = FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="zha", + owners=[owner1, owner2], + ) + + # Both running + owner1.is_running.return_value = True + owner2.is_running.return_value = True + assert (await firmware_info.is_running(hass)) is True + + # Only one running + owner1.is_running.return_value = True + owner2.is_running.return_value = False + assert (await firmware_info.is_running(hass)) is False + + # No owners + firmware_info2 = FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="zha", + owners=[], + ) + + assert (await firmware_info2.is_running(hass)) is False diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 15eeb205537..8e90039a4fc 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant @@ -32,11 +32,13 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( - "homeassistant.components.homeassistant_sky_connect.guess_firmware_type", - return_value=FirmwareGuess( - is_running=True, + "homeassistant.components.homeassistant_sky_connect.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + firmware_version=None, firmware_type=ApplicationType.SPINEL, source="otbr", + owners=[], ), ): await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 5d534dad1e7..57d63c7441e 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import zha from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, - FirmwareGuess, + FirmwareInfo, ) from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -49,11 +49,13 @@ async def test_setup_entry( return_value=onboarded, ), patch( - "homeassistant.components.homeassistant_yellow.guess_firmware_type", - return_value=FirmwareGuess( # Nothing is setup - is_running=False, + "homeassistant.components.homeassistant_yellow.guess_firmware_info", + return_value=FirmwareInfo( # Nothing is setup + device="/dev/ttyAMA1", + firmware_version=None, firmware_type=ApplicationType.EZSP, source="unknown", + owners=[], ), ), ): diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 692383b4794..91b1e30e4f8 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -782,6 +782,174 @@ 'state': '230.0', }) # --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi RSSI', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_rssi', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_rssi', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_rssi:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi RSSI', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-77', + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Plug-In Battery', + 'model_id': 'HWE-BAT', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.00', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'simulating v1 support', + }) +# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -14363,6 +14531,174 @@ 'state': '0.0', }) # --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi SSID', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_ssid', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_ssid:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi SSID', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'My Wi-Fi', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:device-registry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi strength', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_strength', + 'unique_id': 'HWE-P1_5c2fafabcdef_wifi_strength', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[HWE-P1-zero-values-entity_ids1][sensor.device_wi_fi_strength:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Wi-Fi strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.device_wi_fi_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_sensors[HWE-SKT-11-entity_ids2][sensor.device_energy_export:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 412ddb13eda..9139ef80d12 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock +import weakref from freezegun.api import FrozenDateTimeFactory from homewizard_energy.errors import DisabledError, UnauthorizedError @@ -25,6 +26,9 @@ async def test_load_unload_v1( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + weak_ref = weakref.ref(mock_config_entry.runtime_data) + assert weak_ref() is not None + assert mock_config_entry.state is ConfigEntryState.LOADED assert len(mock_homewizardenergy.combined.mock_calls) == 1 @@ -32,6 +36,7 @@ async def test_load_unload_v1( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert weak_ref() is None async def test_load_unload_v2( diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index 94a59551eb4..fe709570239 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -108,6 +108,8 @@ pytestmark = [ "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_ssid", + "sensor.device_wi_fi_strength", ], ), ( @@ -304,6 +306,8 @@ pytestmark = [ "sensor.device_state_of_charge", "sensor.device_uptime", "sensor.device_voltage", + "sensor.device_wi_fi_rssi", + "sensor.device_wi_fi_ssid", ], ), ], @@ -453,6 +457,7 @@ async def test_sensors( "sensor.device_frequency", "sensor.device_uptime", "sensor.device_voltage", + "sensor.device_wi_fi_rssi", ], ), ], @@ -561,6 +566,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -610,6 +616,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -667,6 +674,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -718,6 +726,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -758,6 +767,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -809,6 +819,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -849,6 +860,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_3", "sensor.device_voltage", "sensor.device_water_usage", + "sensor.device_wi_fi_rssi", ], ), ( @@ -897,6 +909,7 @@ async def test_external_sensors_unreachable( "sensor.device_voltage_swells_detected_phase_2", "sensor.device_voltage_swells_detected_phase_3", "sensor.device_water_usage", + "sensor.device_wi_fi_strength", ], ), ], diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index ef8398b3d17..71218010b45 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -3,17 +3,22 @@ A KNXTestKit instance can be requested from a fixture. It provides convenience methods to test outgoing KNX telegrams and inject incoming telegrams. To test something add a test function requesting the `hass` and `knx` fixture and -set up the KNX integration by passing a KNX config dict to `knx.setup_integration`. +set up the KNX integration with `knx.setup_integration`. +You can pass a KNX YAML-config dict or a ConfigStore fixture filename to the setup method. The fixture should be placed in the `tests/components/knx/fixtures` directory. ```python -async def test_something(hass, knx): - await knx.setup_integration({ +async def test_some_yaml(hass: HomeAssistant, knx: KNXTestKit): + await knx.setup_integration( + yaml_config={ "switch": { "name": "test_switch", "address": "1/2/3", } } ) + +async def test_some_config_store(hass: HomeAssistant, knx: KNXTestKit): + await knx.setup_integration(config_store_fixture="config_store_filename.json") ``` ## Asserting outgoing telegrams diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 4e50836bb79..c9092a1774f 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -44,7 +44,6 @@ from tests.common import MockConfigEntry, load_json_object_fixture from tests.typing import WebSocketGenerator FIXTURE_PROJECT_DATA = load_json_object_fixture("project.json", KNX_DOMAIN) -FIXTURE_CONFIG_STORAGE_DATA = load_json_object_fixture("config_store.json", KNX_DOMAIN) class KNXTestKit: @@ -52,10 +51,16 @@ class KNXTestKit: INDIVIDUAL_ADDRESS = "1.2.3" - def __init__(self, hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], + ) -> None: """Init KNX test helper class.""" self.hass: HomeAssistant = hass self.mock_config_entry: MockConfigEntry = mock_config_entry + self.hass_storage: dict[str, Any] = hass_storage self.xknx: XKNX # outgoing telegrams will be put in the List instead of sent to the interface # telegrams to an InternalGroupAddress won't be queued here @@ -69,7 +74,10 @@ class KNXTestKit: assert test_state.attributes.get(attribute) == value async def setup_integration( - self, config: ConfigType, add_entry_to_hass: bool = True + self, + yaml_config: ConfigType | None = None, + config_store_fixture: str | None = None, + add_entry_to_hass: bool = True, ) -> None: """Create the KNX integration.""" @@ -101,15 +109,21 @@ class KNXTestKit: self.xknx = args[0] return DEFAULT + if config_store_fixture: + self.hass_storage[KNX_CONFIG_STORAGE_KEY] = load_json_object_fixture( + config_store_fixture, KNX_DOMAIN + ) + if add_entry_to_hass: self.mock_config_entry.add_to_hass(self.hass) + knx_config = {KNX_DOMAIN: yaml_config or {}} with patch( "xknx.xknx.knx_interface_factory", return_value=knx_ip_interface_mock(), side_effect=fish_xknx, ): - await async_setup_component(self.hass, KNX_DOMAIN, {KNX_DOMAIN: config}) + await async_setup_component(self.hass, KNX_DOMAIN, knx_config) await self.hass.async_block_till_done() ######################## @@ -306,9 +320,13 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -async def knx(hass: HomeAssistant, mock_config_entry: MockConfigEntry): +async def knx( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + hass_storage: dict[str, Any], +): """Create a KNX TestKit instance.""" - knx_test_kit = KNXTestKit(hass, mock_config_entry) + knx_test_kit = KNXTestKit(hass, mock_config_entry, hass_storage) yield knx_test_kit await knx_test_kit.assert_no_telegram() @@ -322,12 +340,6 @@ def load_knxproj(hass_storage: dict[str, Any]) -> None: } -@pytest.fixture -def load_config_store(hass_storage: dict[str, Any]) -> None: - """Mock KNX config store data.""" - hass_storage[KNX_CONFIG_STORAGE_KEY] = FIXTURE_CONFIG_STORAGE_DATA - - @pytest.fixture async def create_ui_entity( hass: HomeAssistant, diff --git a/tests/components/knx/fixtures/config_store_binarysensor.json b/tests/components/knx/fixtures/config_store_binarysensor.json new file mode 100644 index 00000000000..427867cff8c --- /dev/null +++ b/tests/components/knx/fixtures/config_store_binarysensor.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "minor_version": 1, + "key": "knx/config_store.json", + "data": { + "entities": { + "light": {}, + "binary_sensor": { + "knx_es_01JJP1XDQRXB0W6YYGXW6Y1X10": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_sensor": { + "state": "3/2/21", + "passive": [] + }, + "respond_to_read": false, + "sync_state": true + } + } + } + } + } +} diff --git a/tests/components/knx/fixtures/config_store.json b/tests/components/knx/fixtures/config_store_light_switch.json similarity index 100% rename from tests/components/knx/fixtures/config_store.json rename to tests/components/knx/fixtures/config_store_light_switch.json diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 4b58801a8a0..b93b7e965df 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -329,7 +329,7 @@ async def test_binary_sensor_ui_create( knx_data: dict[str, Any], ) -> None: """Test creating a binary sensor.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.BINARY_SENSOR, entity_data={"name": "test"}, @@ -340,3 +340,10 @@ async def test_binary_sensor_ui_create( await knx.receive_response("2/2/2", not knx_data.get("invert")) state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON + + +async def test_binary_sensor_ui_load(knx: KNXTestKit) -> None: + """Test loading a binary sensor from storage.""" + await knx.setup_integration(config_store_fixture="config_store_binarysensor.json") + await knx.assert_read("3/2/21", response=True, ignore_order=True) + knx.assert_state("binary_sensor.test", STATE_ON) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 8ed79f837bb..3e4c9408542 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1278,7 +1278,7 @@ async def test_options_flow_connection_type( # usage of the already running XKNX instance for gateway scanner gateway = _gateway_descriptor("192.168.0.1", 3675) - await knx.setup_integration({}) + await knx.setup_integration() menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) with patch( diff --git a/tests/components/knx/test_config_store.py b/tests/components/knx/test_config_store.py index 116f4b5d839..aee0a4036ff 100644 --- a/tests/components/knx/test_config_store.py +++ b/tests/components/knx/test_config_store.py @@ -25,7 +25,7 @@ async def test_create_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_name = "Test no device" @@ -69,7 +69,7 @@ async def test_create_entity_error( hass_ws_client: WebSocketGenerator, ) -> None: """Test unsuccessful entity creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) # create entity with invalid platform @@ -116,7 +116,7 @@ async def test_update_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -163,7 +163,7 @@ async def test_update_entity_error( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity update.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -238,7 +238,7 @@ async def test_delete_entity( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity deletion.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -270,7 +270,7 @@ async def test_delete_entity_error( hass_storage: dict[str, Any], ) -> None: """Test unsuccessful entity deletion.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) # delete unknown entity @@ -307,7 +307,7 @@ async def test_get_entity_config( create_ui_entity: KnxEntityGenerator, ) -> None: """Test entity config retrieval.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) test_entity = await create_ui_entity( @@ -355,7 +355,7 @@ async def test_get_entity_config_error( error_message_start: str, ) -> None: """Test entity config retrieval errors.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -376,7 +376,7 @@ async def test_validate_entity( hass_ws_client: WebSocketGenerator, ) -> None: """Test entity validation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py index 04ff02f0611..356640dd8d0 100644 --- a/tests/components/knx/test_device.py +++ b/tests/components/knx/test_device.py @@ -22,7 +22,7 @@ async def test_create_device( hass_ws_client: WebSocketGenerator, ) -> None: """Test device creation.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -50,12 +50,11 @@ async def test_remove_device( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - load_config_store: None, hass_storage: dict[str, Any], ) -> None: """Test device removal.""" assert await async_setup_component(hass, "config", {}) - await knx.setup_integration({}) + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") client = await hass_ws_client(hass) await knx.assert_read("1/0/21", response=True, ignore_order=True) # test light diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index e5f776a9404..e4a208906c6 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -28,7 +28,7 @@ async def test_if_fires_on_telegram( knx: KNXTestKit, ) -> None: """Test telegram device triggers firing.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -124,7 +124,7 @@ async def test_default_if_fires_on_telegram( # by default (without a user changing any) extra_fields are not added to the trigger and # pre 2024.2 device triggers did only support "destination" field so they didn't have # "group_value_write", "group_value_response", "group_value_read", "incoming", "outgoing" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -206,7 +206,7 @@ async def test_remove_device_trigger( ) -> None: """Test for removed callback when device trigger not used.""" automation_name = "telegram_trigger_automation" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -256,7 +256,7 @@ async def test_get_triggers( knx: KNXTestKit, ) -> None: """Test we get the expected device triggers from knx.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -279,7 +279,7 @@ async def test_get_trigger_capabilities( knx: KNXTestKit, ) -> None: """Test we get the expected capabilities telegram device trigger.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -361,7 +361,7 @@ async def test_invalid_device_trigger( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid telegram device trigger configuration.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) @@ -404,7 +404,7 @@ async def test_invalid_trigger_configuration( knx: KNXTestKit, ) -> None: """Test invalid telegram device trigger configuration at attach_trigger.""" - await knx.setup_integration({}) + await knx.setup_integration() device_entry = device_registry.async_get_device( identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} ) diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index bb60e66f7e7..6d4bf7e6007 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the KNX integration.""" +from typing import Any + import pytest from syrupy import SnapshotAssertion from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT @@ -40,7 +42,7 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -60,7 +62,7 @@ async def test_diagnostic_config_error( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -76,6 +78,7 @@ async def test_diagnostic_config_error( async def test_diagnostic_redact( hass: HomeAssistant, hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test diagnostics redacting data.""" @@ -95,8 +98,8 @@ async def test_diagnostic_redact( CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", }, ) - knx: KNXTestKit = KNXTestKit(hass, mock_config_entry) - await knx.setup_integration({}) + knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage) + await knx.setup_integration() # Overwrite the version for this test since we don't want to change this with every library bump knx.xknx.version = "0.0.0" @@ -117,7 +120,7 @@ async def test_diagnostics_project( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - await knx.setup_integration({}) + await knx.setup_integration() knx.xknx.version = "0.0.0" # snapshot will contain project specific fields in `project_info` assert ( diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 75cd5d1eb21..579f9b143a2 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -226,7 +226,7 @@ async def test_init_connection_handling( data=config_entry_data, ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() assert hass.data.get(KNX_DOMAIN) is not None @@ -280,7 +280,7 @@ async def _init_switch_and_wait_for_first_state_updater_run( title="KNX", domain=KNX_DOMAIN, data=config_entry_data ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.SWITCH, knx_data={ @@ -354,7 +354,7 @@ async def test_async_remove_entry( }, ) knx.mock_config_entry = config_entry - await knx.setup_integration({}) + await knx.setup_integration() with ( patch("pathlib.Path.unlink") as unlink_mock, diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 79114d4ffd5..4de366c69f0 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -25,7 +25,7 @@ async def test_diagnostic_entities( freezer: FrozenDateTimeFactory, ) -> None: """Test diagnostic entities.""" - await knx.setup_integration({}) + await knx.setup_integration() for entity_id in ( "sensor.knx_interface_individual_address", @@ -103,7 +103,7 @@ async def test_removed_entity( with patch( "xknx.core.connection_manager.ConnectionManager.unregister_connection_state_changed_cb" ) as unregister_mock: - await knx.setup_integration({}) + await knx.setup_integration() entity_registry.async_update_entity( "sensor.knx_interface_connection_established", @@ -120,7 +120,7 @@ async def test_remove_interface_device( ) -> None: """Test device removal.""" assert await async_setup_component(hass, "config", {}) - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) knx_devices = device_registry.devices.get_devices_for_config_entry_id( knx.mock_config_entry.entry_id diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 6ba6090d60d..fb0246763a4 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -1176,7 +1176,7 @@ async def test_light_ui_create( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a light.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1213,7 +1213,7 @@ async def test_light_ui_color_temp( raw_ct: tuple[int, ...], ) -> None: """Test creating a color-temp light.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1250,7 +1250,7 @@ async def test_light_ui_multi_mode( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a light with multiple color modes.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.LIGHT, entity_data={"name": "test"}, @@ -1335,13 +1335,11 @@ async def test_light_ui_multi_mode( async def test_light_ui_load( - hass: HomeAssistant, knx: KNXTestKit, - load_config_store: None, entity_registry: er.EntityRegistry, ) -> None: """Test loading a light from storage.""" - await knx.setup_integration({}) + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") await knx.assert_read("1/0/21", response=True, ignore_order=True) # unrelated switch in config store diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py index f70389dbc92..c4b48b5e81d 100644 --- a/tests/components/knx/test_services.py +++ b/tests/components/knx/test_services.py @@ -111,7 +111,7 @@ async def test_send( expected_apci, ) -> None: """Test `knx.send` service.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.services.async_call( "knx", @@ -127,7 +127,7 @@ async def test_send( async def test_read(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test `knx.read` service.""" - await knx.setup_integration({}) + await knx.setup_integration() # send read telegram await hass.services.async_call("knx", "read", {"address": "1/1/1"}, blocking=True) @@ -150,7 +150,7 @@ async def test_event_register(hass: HomeAssistant, knx: KNXTestKit) -> None: events = async_capture_events(hass, "knx_event") test_address = "1/2/3" - await knx.setup_integration({}) + await knx.setup_integration() # no event registered await knx.receive_write(test_address, True) @@ -200,7 +200,7 @@ async def test_exposure_register(hass: HomeAssistant, knx: KNXTestKit) -> None: test_entity = "fake.entity" test_attribute = "fake_attribute" - await knx.setup_integration({}) + await knx.setup_integration() # no exposure registered hass.states.async_set(test_entity, STATE_ON, {}) @@ -265,7 +265,7 @@ async def test_reload_service( knx: KNXTestKit, ) -> None: """Test reload service.""" - await knx.setup_integration({}) + await knx.setup_integration() with ( patch( @@ -285,7 +285,7 @@ async def test_reload_service( async def test_service_setup_failed(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test service setup failed.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) with pytest.raises(HomeAssistantError) as exc_info: diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index bc0a6b27675..969c11b8e1a 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -155,7 +155,7 @@ async def test_switch_ui_create( create_ui_entity: KnxEntityGenerator, ) -> None: """Test creating a switch.""" - await knx.setup_integration({}) + await knx.setup_integration() await create_ui_entity( platform=Platform.SWITCH, entity_data={"name": "test"}, @@ -171,3 +171,16 @@ async def test_switch_ui_create( await knx.receive_response("2/2/2", True) state = hass.states.get("switch.test") assert state.state is STATE_ON + + +async def test_switch_ui_load(knx: KNXTestKit) -> None: + """Test loading a switch from storage.""" + await knx.setup_integration(config_store_fixture="config_store_light_switch.json") + + await knx.assert_read("1/0/45", response=True, ignore_order=True) + # unrelated light in config store + await knx.assert_read("1/0/21", response=True, ignore_order=True) + knx.assert_state( + "switch.none_test", # has_entity_name with unregistered device -> none_test + STATE_ON, + ) diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 883e8ccbb2d..840959bb6c5 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -70,7 +70,7 @@ async def test_store_telegam_history( hass_storage: dict[str, Any], ) -> None: """Test storing telegram history.""" - await knx.setup_integration({}) + await knx.setup_integration() await knx.receive_write("1/3/4", True) await hass.services.async_call( @@ -94,7 +94,7 @@ async def test_load_telegam_history( ) -> None: """Test telegram history restoration.""" hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} - await knx.setup_integration({}) + await knx.setup_integration() loaded_telegrams = hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams assert assert_telegram_history(loaded_telegrams) # TelegramDict "payload" is a tuple, this shall be restored when loading from JSON @@ -113,7 +113,7 @@ async def test_remove_telegam_history( knx.mock_config_entry, data=knx.mock_config_entry.data | {CONF_KNX_TELEGRAM_LOG_SIZE: 0}, ) - await knx.setup_integration({}, add_entry_to_hass=False) + await knx.setup_integration(add_entry_to_hass=False) # Store.async_remove() is mocked by hass_storage - check that data was removed. assert "knx/telegrams_history.json" not in hass_storage assert not hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams diff --git a/tests/components/knx/test_trigger.py b/tests/components/knx/test_trigger.py index 73e8b10840e..1ce42a23482 100644 --- a/tests/components/knx/test_trigger.py +++ b/tests/components/knx/test_trigger.py @@ -18,7 +18,7 @@ async def test_telegram_trigger( knx: KNXTestKit, ) -> None: """Test telegram triggers firing.""" - await knx.setup_integration({}) + await knx.setup_integration() # "id" field added to action to test if `trigger_data` passed correctly in `async_attach_trigger` assert await async_setup_component( @@ -105,7 +105,7 @@ async def test_telegram_trigger_dpt_option( expected_unit: str | None, ) -> None: """Test telegram trigger type option.""" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, automation.DOMAIN, @@ -190,7 +190,7 @@ async def test_telegram_trigger_options( direction_options: dict[str, bool], ) -> None: """Test telegram trigger options.""" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, automation.DOMAIN, @@ -266,7 +266,7 @@ async def test_remove_telegram_trigger( ) -> None: """Test for removed callback when telegram trigger not used.""" automation_name = "telegram_trigger_automation" - await knx.setup_integration({}) + await knx.setup_integration() assert await async_setup_component( hass, @@ -311,7 +311,7 @@ async def test_invalid_trigger( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid telegram trigger configuration.""" - await knx.setup_integration({}) + await knx.setup_integration() caplog.clear() with caplog.at_level(logging.ERROR): assert await async_setup_component( diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index a34f126e4f4..7054d415ee9 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -20,7 +20,7 @@ async def test_knx_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/info command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/info"}) @@ -39,7 +39,7 @@ async def test_knx_info_command_with_project( load_knxproj: None, ) -> None: """Test knx/info command with loaded project.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/info"}) @@ -65,7 +65,7 @@ async def test_knx_project_file_process( _password = "pw-test" _parse_result = FIXTURE_PROJECT_DATA - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded @@ -100,7 +100,7 @@ async def test_knx_project_file_process_error( hass_ws_client: WebSocketGenerator, ) -> None: """Test knx/project_file_process exception handling.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded @@ -134,7 +134,7 @@ async def test_knx_project_file_remove( hass_storage: dict[str, Any], ) -> None: """Test knx/project_file_remove command.""" - await knx.setup_integration({}) + await knx.setup_integration() assert hass_storage[KNX_PROJECT_STORAGE_KEY] client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded @@ -154,7 +154,7 @@ async def test_knx_get_project( load_knxproj: None, ) -> None: """Test retrieval of kxnproject from store.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded @@ -169,7 +169,7 @@ async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/group_monitor_info command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) @@ -184,7 +184,7 @@ async def test_knx_group_telegrams_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/group_telegrams command.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "knx/group_telegrams"}) @@ -338,7 +338,7 @@ async def test_knx_subscribe_telegrams_command_project( load_knxproj: None, ) -> None: """Test knx/subscribe_telegrams command with project data.""" - await knx.setup_integration({}) + await knx.setup_integration() client = await hass_ws_client(hass) await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) res = await client.receive_json() @@ -405,7 +405,7 @@ async def test_websocket_when_config_entry_unloaded( endpoint: str, ) -> None: """Test websocket connection when config entry is unloaded.""" - await knx.setup_integration({}) + await knx.setup_integration() await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) client = await hass_ws_client(hass) diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json index 0d45dc5c9f4..85ce95da0ed 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -57,6 +57,16 @@ "type": "number" } }, + "filterInfo": { + "filterLifetime": { + "mode": ["r"], + "type": "number" + }, + "usedTime": { + "mode": ["r"], + "type": "number" + } + }, "operation": { "airCleanOperationMode": { "mode": ["w"], @@ -124,6 +134,52 @@ } } }, + "temperatureInUnits": [ + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": "C" + }, + { + "currentTemperature": { + "type": "number", + "mode": ["r"] + }, + "targetTemperature": { + "type": "number", + "mode": ["r"] + }, + "coolTargetTemperature": { + "type": "range", + "mode": ["w"], + "value": { + "w": { + "max": 86, + "min": 64, + "step": 2 + } + } + }, + "unit": "F" + } + ], "timer": { "relativeHourToStart": { "mode": ["r", "w"], @@ -149,6 +205,24 @@ "mode": ["r", "w"], "type": "number" } + }, + "windDirection": { + "rotateUpDown": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + }, + "rotateLeftRight": { + "type": "boolean", + "mode": ["r", "w"], + "value": { + "r": [true, false], + "w": [true, false] + } + } } } } diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json index 90d15d1ae16..8440e7da28c 100644 --- a/tests/components/lg_thinq/fixtures/air_conditioner/status.json +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -32,6 +32,19 @@ "targetTemperature": 19, "unit": "C" }, + "temperatureInUnits": [ + { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + { + "currentTemperature": 77, + "targetTemperature": 66, + "unit": "F" + } + ], + "timer": { "relativeStartTimer": "UNSET", "relativeStopTimer": "UNSET", @@ -39,5 +52,9 @@ "absoluteStopTimer": "UNSET", "absoluteHourToStart": 13, "absoluteMinuteToStart": 14 + }, + "windDirection": { + "rotateUpDown": false, + "rotateLeftRight": false } } diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index e9470c3de03..9369367a1f7 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -43,7 +43,7 @@ 'original_name': None, 'platform': 'lg_thinq', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': , 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', 'unit_of_measurement': None, @@ -72,7 +72,9 @@ 'preset_modes': list([ 'air_clean', ]), - 'supported_features': , + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, 'target_temp_step': 1, 'temperature': 19, }), diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 2c58b109e61..fe1929944f9 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_filter_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter remaining', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_filter_lifetime', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_filter_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Filter remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_filter_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '540', + }) +# --- # name: test_all_entities[sensor.test_air_conditioner_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py index 640264eb207..8f1e85f28ff 100644 --- a/tests/components/linear_garage_door/test_init.py +++ b/tests/components/linear_garage_door/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock from linear_garage_door import InvalidLoginError import pytest +from homeassistant.components.linear_garage_door.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from . import setup_integration @@ -51,3 +53,23 @@ async def test_setup_failure( await setup_integration(hass, mock_config_entry, []) assert mock_config_entry.state == entry_state + + +async def test_repair_issue( + hass: HomeAssistant, + mock_linear: AsyncMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test reauth trigger setup.""" + + await setup_integration(hass, mock_config_entry, []) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py new file mode 100644 index 00000000000..bb68c67ce62 --- /dev/null +++ b/tests/components/motionmount/test_sensor.py @@ -0,0 +1,48 @@ +"""Tests for the MotionMount Sensor platform.""" + +from unittest.mock import patch + +from motionmount import MotionMountSystemError +import pytest + +from homeassistant.core import HomeAssistant + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") + + +@pytest.mark.parametrize( + ("system_status", "state"), + [ + (None, "none"), + (MotionMountSystemError.MotorError, "motor"), + (MotionMountSystemError.ObstructionDetected, "obstruction"), + (MotionMountSystemError.TVWidthConstraintError, "tv_width_constraint"), + (MotionMountSystemError.HDMICECError, "hdmi_cec"), + (MotionMountSystemError.InternalError, "internal"), + ], +) +async def test_error_status_sensor_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + system_status: MotionMountSystemError, + state: str, +) -> None: + """Tests the state attributes.""" + with patch( + "homeassistant.components.motionmount.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + motionmount_mock.return_value.name = ZEROCONF_NAME + motionmount_mock.return_value.mac = MAC + motionmount_mock.return_value.is_authenticated = True + motionmount_mock.return_value.system_status = [system_status] + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert hass.states.get("sensor.my_motionmount_error_status").state == state diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index d05c340dac2..b2dd3d048ec 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -391,6 +391,25 @@ async def test_service_call_with_ascii_qos_retain_flags( blocking=True, ) assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "" + assert mqtt_mock.async_publish.call_args[0][2] == 2 + assert not mqtt_mock.async_publish.call_args[0][3] + + mqtt_mock.reset_mock() + + # Test service call without payload + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_QOS: "2", + mqtt.ATTR_RETAIN: "no", + }, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] is None assert mqtt_mock.async_publish.call_args[0][2] == 2 assert not mqtt_mock.async_publish.call_args[0][3] diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index e76ce1d01c8..0d6ee09d587 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -67,8 +67,8 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture(autouse=True) -def mock_onedrive_client() -> Generator[MagicMock]: +@pytest.fixture +def mock_onedrive_client_init() -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" with ( patch( @@ -80,19 +80,25 @@ def mock_onedrive_client() -> Generator[MagicMock]: new=onedrive_client, ), ): - client = onedrive_client.return_value - client.get_approot.return_value = MOCK_APPROOT - client.create_folder.return_value = MOCK_BACKUP_FOLDER - client.list_drive_items.return_value = [MOCK_BACKUP_FILE] - client.get_drive_item.return_value = MOCK_BACKUP_FILE + yield onedrive_client - class MockStreamReader: - async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: - yield b"backup data" - client.download_drive_item.return_value = MockStreamReader() +@pytest.fixture(autouse=True) +def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[MagicMock]: + """Return a mocked GraphServiceClient.""" + client = mock_onedrive_client_init.return_value + client.get_approot.return_value = MOCK_APPROOT + client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.get_drive_item.return_value = MOCK_BACKUP_FILE - yield client + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" + + client.download_drive_item.return_value = MockStreamReader() + + return client @pytest.fixture diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 9acfd8ada3c..fb0d58b86c6 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -70,6 +70,7 @@ async def test_full_flow( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, + mock_onedrive_client_init: MagicMock, ) -> None: """Check full flow.""" @@ -79,6 +80,10 @@ async def test_full_flow( await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert result["type"] is FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 674bc2d38d9..a6ad55442aa 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -16,10 +16,15 @@ from tests.common import MockConfigEntry async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + mock_onedrive_client_init: MagicMock, ) -> None: """Test loading and unloading the integration.""" await setup_integration(hass, mock_config_entry) + # Ensure the token callback is set up correctly + token_callback = mock_onedrive_client_init.call_args[0][0] + assert await token_callback() == "mock-access-token" + assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index 40028e1f80f..2b3388444d2 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "enabled": true, "types": 222, "options": { - "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_idd\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", + "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_id\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" } } diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 362adebe416..524aad873f9 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from smhi.smhi_lib import SmhiForecastException +from pysmhi import SmhiForecastException from homeassistant import config_entries from homeassistant.components.smhi.const import DOMAIN @@ -31,7 +31,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -66,7 +66,7 @@ async def test_form(hass: HomeAssistant) -> None: ) with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -102,7 +102,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException, ): result2 = await hass.config_entries.flow.async_configure( @@ -122,7 +122,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: # Continue flow with new coordinates with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( @@ -170,7 +170,7 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ): result2 = await hass.config_entries.flow.async_configure( @@ -218,7 +218,7 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM with patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException, ): result = await hass.config_entries.flow.async_configure( @@ -237,7 +237,7 @@ async def test_reconfigure_flow( with ( patch( - "homeassistant.components.smhi.config_flow.Smhi.async_get_forecast", + "homeassistant.components.smhi.config_flow.SMHIPointForecast.async_get_daily_forecast", return_value={"test": "something", "test2": "something else"}, ), patch( diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index d00742d4900..f301e684e3e 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,6 +1,6 @@ """Test SMHI component setup process.""" -from smhi.smhi_lib import APIURL_TEMPLATE +from pysmhi.const import API_POINT_FORECAST from homeassistant.components.smhi.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -17,7 +17,7 @@ async def test_setup_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test setup entry.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -35,7 +35,7 @@ async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test remove entry.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -62,7 +62,7 @@ async def test_migrate_entry( api_response: str, ) -> None: """Test migrate entry data.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -97,7 +97,7 @@ async def test_migrate_from_future_version( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test migrate entry not possible from future version.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) aioclient_mock.get(uri, text=api_response) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index cc6902710bd..a39cb72d4b8 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -4,8 +4,9 @@ from datetime import datetime, timedelta from unittest.mock import patch from freezegun import freeze_time +from pysmhi import SMHIForecast, SmhiForecastException +from pysmhi.const import API_POINT_FORECAST import pytest -from smhi.smhi_lib import APIURL_TEMPLATE, SmhiForecast, SmhiForecastException from syrupy.assertion import SnapshotAssertion from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY @@ -44,7 +45,7 @@ async def test_setup_hass( snapshot: SnapshotAssertion, ) -> None: """Test for successfully setting up the smhi integration.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -54,7 +55,7 @@ async def test_setup_hass( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 # Testing the actual entity state for # deeper testing than normal unity test @@ -75,7 +76,7 @@ async def test_clear_night( """Test for successfully setting up the smhi integration.""" hass.config.latitude = "59.32624" hass.config.longitude = "17.84197" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response_night) @@ -85,7 +86,7 @@ async def test_clear_night( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 state = hass.states.get(ENTITY_ID) @@ -109,7 +110,7 @@ async def test_properties_no_data(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException("boom"), ): await hass.config_entries.async_setup(entry.entry_id) @@ -134,61 +135,77 @@ async def test_properties_no_data(hass: HomeAssistant) -> None: async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: """Test behaviour when unknown symbol from API.""" - data = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 1, 0, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 1, 0, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) - - data2 = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data2 = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 1, 12, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 1, 12, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) - - data3 = SmhiForecast( - temperature=5, - temperature_max=10, - temperature_min=0, - humidity=5, - pressure=1008, - thunder=0, - cloudiness=52, - precipitation=1, - wind_direction=180, - wind_speed=10, - horizontal_visibility=6, - wind_gust=1.5, - mean_precipitation=0.5, - total_precipitation=1, + data3 = SMHIForecast( + frozen_precipitation=0, + high_cloud=100, + humidity=96, + low_cloud=100, + max_precipitation=0.0, + mean_precipitation=0.0, + median_precipitation=0.0, + medium_cloud=75, + min_precipitation=0.0, + precipitation_category=0, + pressure=1018.9, symbol=100, # Faulty symbol - valid_time=datetime(2018, 1, 2, 12, 1, 2), + temperature=1.0, + temperature_max=1.0, + temperature_min=1.0, + thunder=0, + total_cloud=100, + valid_time=datetime(2018, 1, 2, 0, 0, 0), + visibility=8.8, + wind_direction=114, + wind_gust=5.8, + wind_speed=2.5, ) testdata = [data, data2, data3] @@ -198,11 +215,11 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", return_value=testdata, ), patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast_hour", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_hourly_forecast", return_value=None, ), ): @@ -237,7 +254,7 @@ async def test_refresh_weather_forecast_retry( now = dt_util.utcnow() with patch( - "homeassistant.components.smhi.weather.Smhi.async_get_forecast", + "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", side_effect=error, ) as mock_get_forecast: await hass.config_entries.async_setup(entry.entry_id) @@ -352,7 +369,7 @@ async def test_custom_speed_unit( api_response: str, ) -> None: """Test Wind Gust speed with custom unit.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -389,7 +406,7 @@ async def test_forecast_services( snapshot: SnapshotAssertion, ) -> None: """Test multiple forecast.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) @@ -440,7 +457,7 @@ async def test_forecast_services( assert msg["type"] == "event" forecast1 = msg["event"]["forecast"] - assert len(forecast1) == 72 + assert len(forecast1) == 52 assert forecast1[0] == snapshot assert forecast1[6] == snapshot @@ -453,7 +470,7 @@ async def test_forecast_services_lack_of_data( snapshot: SnapshotAssertion, ) -> None: """Test forecast lacking data.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response_lack_data) @@ -498,7 +515,7 @@ async def test_forecast_service( service: str, ) -> None: """Test forecast service.""" - uri = APIURL_TEMPLATE.format( + uri = API_POINT_FORECAST.format( TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] ) aioclient_mock.get(uri, text=api_response) diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 9ecffd395a3..4d6794b962f 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -274,3 +274,23 @@ LEAK_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, tx_power=-127, ) + +REMOTE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Any", + manufacturer_data={89: b"\xaa\xbb\xcc\xdd\xee\xff"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"b V\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Any"), + time=0, + connectable=False, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index acf1bacc054..6a7111a054e 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.setup import async_setup_component from . import ( LEAK_SERVICE_INFO, + REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, WOMETERTHPC_SERVICE_INFO, WORELAY_SWITCH_1PM_SERVICE_INFO, @@ -194,3 +195,42 @@ async def test_leak_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remote(hass: HomeAssistant) -> None: + """Test setting up the remote sensor.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, REMOTE_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "remote", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 2 + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "86" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py index ce570499b3a..42fe3e4f543 100644 --- a/tests/components/switchbot_cloud/__init__.py +++ b/tests/components/switchbot_cloud/__init__.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -def configure_integration(hass: HomeAssistant) -> MockConfigEntry: +async def configure_integration(hass: HomeAssistant) -> MockConfigEntry: """Configure the integration.""" config = { CONF_API_TOKEN: "test-token", @@ -17,5 +17,7 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" ) entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/switchbot_cloud/fixtures/meter_status.json b/tests/components/switchbot_cloud/fixtures/meter_status.json new file mode 100644 index 00000000000..8b5bcd0c031 --- /dev/null +++ b/tests/components/switchbot_cloud/fixtures/meter_status.json @@ -0,0 +1,9 @@ +{ + "version": "V3.3", + "temperature": 21.8, + "battery": 100, + "humidity": 32, + "deviceId": "meter-id-1", + "deviceType": "Meter", + "hubDeviceId": "test-hub-id" +} diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a9b6fb20bfb --- /dev/null +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -0,0 +1,307 @@ +# serializer version: 1 +# name: test_meter[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_meter[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.8', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'meter-1 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'meter-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.meter_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.meter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'switchbot_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'meter-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_meter_no_coordinator_data[sensor.meter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'meter-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index df5b7569100..0779e54ee03 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -28,10 +28,7 @@ async def test_pressmode_bot( mock_get_status.return_value = {"deviceMode": "pressMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "button.bot_1" @@ -63,9 +60,6 @@ async def test_switchmode_bot_no_button_entity( mock_get_status.return_value = {"deviceMode": "switchMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(BUTTON_DOMAIN) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index d5728faf369..f4837c4e97e 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -64,9 +64,7 @@ async def test_setup_entry_success( ), ] mock_get_status.return_value = {"power": PowerState.ON.value} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -91,8 +89,7 @@ async def test_setup_entry_fails_when_listing_devices( ) -> None: """Test error handling when list_devices in setup of entry.""" mock_list_devices.side_effect = error - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) + entry = await configure_integration(hass) assert entry.state == state hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -114,8 +111,7 @@ async def test_setup_entry_fails_when_refreshing( ) ] mock_get_status.side_effect = CannotConnect - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.SETUP_RETRY hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index a09d7241794..fcb81abfc51 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -26,9 +26,7 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> mock_get_status.return_value = {"lockState": "locked"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py new file mode 100644 index 00000000000..6b0a52800f3 --- /dev/null +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -0,0 +1,65 @@ +"""Test for the switchbot_cloud sensors.""" + +from unittest.mock import patch + +from switchbot_api import Device +from syrupy import SnapshotAssertion + +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import configure_integration + +from tests.common import load_json_object_fixture, snapshot_platform + + +async def test_meter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test Meter sensors.""" + + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = load_json_object_fixture("meter_status.json", DOMAIN) + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_meter_no_coordinator_data( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_list_devices, + mock_get_status, +) -> None: + """Test meter sensors are unknown without coordinator data.""" + mock_list_devices.return_value = [ + Device( + deviceId="meter-id-1", + deviceName="meter-1", + deviceType="Meter", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index b1c6fb81b96..99e0f50aa53 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -34,10 +34,7 @@ async def test_relay_switch( mock_get_status.return_value = {"switchStatus": 0} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "switch.relay_switch_1" @@ -71,10 +68,7 @@ async def test_switchmode_bot( mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED entity_id = "switch.bot_1" @@ -108,9 +102,6 @@ async def test_pressmode_bot_no_switch_entity( mock_get_status.return_value = {"deviceMode": "pressMode"} - entry = configure_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(SWITCH_DOMAIN) diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 26e09d407ff..ea68bbc991c 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -36,7 +36,7 @@ from .consts import HOST, MACS, PASSWORD, PORT, SERIAL, USE_SSL, USERNAME from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator -BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_-_2025-01-09_20.14_35457323" +BASE_FILENAME = "Automatic_backup_2025.2.0.dev0_2025-01-09_20.14_35457323" class MockStreamReaderChunked(MockStreamReader): @@ -525,7 +525,7 @@ async def test_agents_upload( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" with ( patch( @@ -576,7 +576,7 @@ async def test_agents_upload_error( protected=True, size=0, ) - base_filename = "Test_-_1970-01-01_00.00_00000000" + base_filename = "Test_1970-01-01_00.00_00000000" # fail to upload the tar file with ( diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index be6b5b31325..c9038003cfc 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -184,7 +184,7 @@ async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> Non assert len(events) == 1 assert events[0].context == context - assert events[0].data[ATTR_MESSAGE_THREAD_ID] == "123" + assert events[0].data[ATTR_MESSAGE_THREAD_ID] == 123 async def test_webhook_endpoint_generates_telegram_text_event( diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 0c2547f309d..90af1259273 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -408,3 +408,78 @@ 'state': 'off', }) # --- +# name: test_select[select.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steering wheel heater', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heat_level', + 'unique_id': 'LRW3F7EK4NC700000-climate_state_steering_wheel_heat_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Steering wheel heater', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select_streaming[select.test_seat_heater_front_left] + 'off' +# --- +# name: test_select_streaming[select.test_seat_heater_front_right] + 'low' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_center] + 'unknown' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_left] + 'medium' +# --- +# name: test_select_streaming[select.test_seat_heater_rear_right] + 'high' +# --- +# name: test_select_streaming[select.test_steering_wheel_heater] + 'off' +# --- diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index 005a6a2004e..c49e83803cd 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode +from teslemetry_stream.const import Signal from homeassistant.components.select import ( ATTR_OPTION, @@ -16,7 +17,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -25,6 +26,7 @@ async def test_select( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities are correct.""" @@ -106,6 +108,7 @@ async def test_select_invalid_data( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the select entities handle invalid data.""" @@ -119,3 +122,45 @@ async def test_select_invalid_data( assert state.state == STATE_UNKNOWN state = hass.states.get("select.test_steering_wheel_heater") assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.SELECT]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SEAT_HEATER_LEFT: 0, + Signal.SEAT_HEATER_RIGHT: 1, + Signal.SEAT_HEATER_REAR_LEFT: 2, + Signal.SEAT_HEATER_REAR_RIGHT: 3, + Signal.HVAC_STEERING_WHEEL_HEAT_LEVEL: 0, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + await reload_platform(hass, entry, [Platform.SELECT]) + + # Assert the entities restored their values + for entity_id in ( + "select.test_seat_heater_front_left", + "select.test_seat_heater_front_right", + "select.test_seat_heater_rear_left", + "select.test_seat_heater_rear_center", + "select.test_seat_heater_rear_right", + "select.test_steering_wheel_heater", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=entity_id) diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index da652b30ac5..a736f1cd186 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -360,7 +360,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', @@ -375,6 +375,7 @@ # name: test_switch_state[Outlet][switch.outlet] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', 'friendly_name': 'Outlet', }), 'context': , @@ -518,7 +519,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'vesync', @@ -533,6 +534,7 @@ # name: test_switch_state[Wall Switch][switch.wall_switch] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'switch', 'friendly_name': 'Wall Switch', }), 'context': , diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 745e77dac5c..b5b02af39b1 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -1,68 +1,4 @@ # serializer version: 1 -# name: test_all_entities[fan.model0_ventilation-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'preset_modes': list([ - , - , - , - , - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.model0_ventilation', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:fan', - 'original_name': 'Ventilation', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': , - 'translation_key': 'ventilation', - 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[fan.model0_ventilation-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'model0 Ventilation', - 'icon': 'mdi:fan', - 'percentage': 0, - 'percentage_step': 25.0, - 'preset_mode': None, - 'preset_modes': list([ - , - , - , - , - ]), - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.model0_ventilation', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[fan.model1_ventilation-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -92,7 +28,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:fan', + 'original_icon': 'mdi:fan-off', 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, @@ -106,7 +42,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model1 Ventilation', - 'icon': 'mdi:fan', + 'icon': 'mdi:fan-off', 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 78d335469b8..96a61a6628b 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -155,6 +155,7 @@ async def zigpy_app_controller(): app.state.node_info.ieee = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32") app.state.node_info.manufacturer = "Coordinator Manufacturer" app.state.node_info.model = "Coordinator Model" + app.state.node_info.version = "7.1.4.0 build 389" app.state.network_info.pan_id = 0x1234 app.state.network_info.extended_pan_id = app.state.node_info.ieee app.state.network_info.channel = 15 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index f46a06e84b8..c9a5e80b1c9 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -75,7 +75,7 @@ 'manufacturer': 'Coordinator Manufacturer', 'model': 'Coordinator Model', 'nwk': 0, - 'version': None, + 'version': '7.1.4.0 build 389', }), }), 'config': dict({ diff --git a/tests/components/zha/test_homeassistant_hardware.py b/tests/components/zha/test_homeassistant_hardware.py new file mode 100644 index 00000000000..72285521182 --- /dev/null +++ b/tests/components/zha/test_homeassistant_hardware.py @@ -0,0 +1,120 @@ +"""Test Home Assistant Hardware platform for ZHA.""" + +from unittest.mock import MagicMock, patch + +import pytest +from zigpy.application import ControllerApplication + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.components.zha.homeassistant_hardware import get_firmware_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_get_firmware_info_normal(hass: HomeAssistant) -> None: + """Test `get_firmware_info`.""" + + zha = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data={ + "device": { + "path": "/dev/ttyUSB1", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, + version=4, + ) + zha.add_to_hass(hass) + zha.mock_state(hass, ConfigEntryState.LOADED) + + # With ZHA running + with patch( + "homeassistant.components.zha.homeassistant_hardware.get_zha_gateway" + ) as mock_get_zha_gateway: + mock_get_zha_gateway.return_value.state.node_info.version = "1.2.3.4" + fw_info_running = get_firmware_info(hass, zha) + + assert fw_info_running == FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version="1.2.3.4", + source="zha", + owners=[OwningIntegration(config_entry_id=zha.entry_id)], + ) + assert await fw_info_running.is_running(hass) is True + + # With ZHA not running + zha.mock_state(hass, ConfigEntryState.NOT_LOADED) + fw_info_not_running = get_firmware_info(hass, zha) + + assert fw_info_not_running == FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="zha", + owners=[OwningIntegration(config_entry_id=zha.entry_id)], + ) + assert await fw_info_not_running.is_running(hass) is False + + +@pytest.mark.parametrize( + "data", + [ + # Missing data + {}, + # Bad radio type + {"device": {"path": "/dev/ttyUSB1"}, "radio_type": "znp"}, + ], +) +async def test_get_firmware_info_errors( + hass: HomeAssistant, data: dict[str, str | int | None] +) -> None: + """Test `get_firmware_info` with config entry data format errors.""" + zha = MockConfigEntry( + domain="zha", + unique_id="some_unique_id", + data=data, + version=4, + ) + zha.add_to_hass(hass) + + assert (get_firmware_info(hass, zha)) is None + + +async def test_hardware_firmware_info_provider_notification( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, +) -> None: + """Test that the ZHA gateway provides hardware and firmware information.""" + config_entry.add_to_hass(hass) + + await async_setup_component(hass, "homeassistant_hardware", {}) + + callback = MagicMock() + async_register_firmware_info_callback(hass, "/dev/ttyUSB0", callback) + + await hass.config_entries.async_setup(config_entry.entry_id) + + callback.assert_called_once_with( + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version="7.1.4.0 build 389", + source="zha", + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + ) + ) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index e288026b67b..630ed3f4fa1 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1170,7 +1170,9 @@ async def test_selector_serializer( async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: """Test the calendar get events tool.""" assert await async_setup_component(hass, "homeassistant", {}) - hass.states.async_set("calendar.test_calendar", "on", {"friendly_name": "Test"}) + hass.states.async_set( + "calendar.test_calendar", "on", {"friendly_name": "Mock Calendar Name"} + ) async_expose_entity(hass, "conversation", "calendar.test_calendar", True) context = Context() llm_context = llm.LLMContext( @@ -1182,7 +1184,11 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: device_id=None, ) api = await llm.async_get_api(hass, "assist", llm_context) - assert [tool for tool in api.tools if tool.name == "calendar_get_events"] + tool = next( + (tool for tool in api.tools if tool.name == "calendar_get_events"), None + ) + assert tool is not None + assert tool.parameters.schema["calendar"].container == ["Mock Calendar Name"] calls = async_mock_service( hass, @@ -1212,7 +1218,10 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: tool_input = llm.ToolInput( tool_name="calendar_get_events", - tool_args={"calendar": "calendar.test_calendar", "range": "today"}, + tool_args={ + "calendar": "Mock Calendar Name", + "range": "today", + }, ) now = dt_util.now() with patch("homeassistant.util.dt.now", return_value=now): diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 539762a60ff..3ad5754dada 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import logging from unittest.mock import AsyncMock, Mock, patch import urllib.error +import weakref import aiohttp from freezegun.api import FrozenDateTimeFactory @@ -12,7 +13,7 @@ import requests from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -898,3 +899,41 @@ async def test_config_entry(hass: HomeAssistant) -> None: hass, _LOGGER, name="test", config_entry=another_entry ) assert crd.config_entry is another_entry + + +async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: + """Test listener subscribe/unsubscribe releases parent class. + + See https://github.com/home-assistant/core/issues/137237 + And https://github.com/home-assistant/core/pull/137338 + """ + + class Subscriber: + _unsub: CALLBACK_TYPE | None = None + + def start_listen( + self, coordinator: update_coordinator.DataUpdateCoordinator + ) -> None: + self._unsub = coordinator.async_add_listener(lambda: None) + + def stop_listen(self) -> None: + self._unsub() + self._unsub = None + + coordinator = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test" + ) + subscriber = Subscriber() + subscriber.start_listen(coordinator) + + # Keep weak reference to the coordinator + weak_ref = weakref.ref(coordinator) + assert weak_ref() is not None + + # Unload the subscriber, then shutdown the coordinator + subscriber.stop_listen() + await coordinator.async_shutdown() + del coordinator + + # Ensure the coordinator is released + assert weak_ref() is None diff --git a/tests/test_setup.py b/tests/test_setup.py index 2d15c670cf7..bb221c7cb4c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -363,20 +363,24 @@ async def test_component_failing_setup(hass: HomeAssistant) -> None: async def test_component_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" - setup.async_set_domains_to_be_loaded(hass, {"comp"}) + domain = "comp" + setup.async_set_domains_to_be_loaded(hass, {domain}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Raise exception.""" raise Exception("fail!") # noqa: TRY002 - mock_integration(hass, MockModule("comp", setup=exception_setup)) + mock_integration(hass, MockModule(domain, setup=exception_setup)) - assert not await setup.async_setup_component(hass, "comp", {}) - assert "comp" not in hass.config.components + assert not await setup.async_setup_component(hass, domain, {}) + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components async def test_component_base_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" + domain = "comp" setup.async_set_domains_to_be_loaded(hass, {"comp"}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -389,7 +393,69 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert "comp" not in hass.config.components + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components + + +async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: + """Test async_set_domains_to_be_loaded.""" + domain_good = "comp_good" + domain_bad = "comp_bad" + domain_base_exception = "comp_base_exception" + domain_exception = "comp_exception" + domains = {domain_good, domain_bad, domain_exception, domain_base_exception} + setup.async_set_domains_to_be_loaded(hass, domains) + + assert set(hass.data[setup.DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + + # Calling async_set_domains_to_be_loaded again should not create new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert setup_done == hass.data[setup.DATA_SETUP_DONE] + + def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Success.""" + return True + + def bad_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Fail.""" + return False + + def base_exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise BaseException("fail!") # noqa: TRY002 + + def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise Exception("fail!") # noqa: TRY002 + + mock_integration(hass, MockModule(domain_good, setup=good_setup)) + mock_integration(hass, MockModule(domain_bad, setup=bad_setup)) + mock_integration( + hass, MockModule(domain_base_exception, setup=base_exception_setup) + ) + mock_integration(hass, MockModule(domain_exception, setup=exception_setup)) + + # Set up the four components + assert await setup.async_setup_component(hass, domain_good, {}) + assert not await setup.async_setup_component(hass, domain_bad, {}) + assert not await setup.async_setup_component(hass, domain_exception, {}) + with pytest.raises(BaseException, match="fail!"): + await setup.async_setup_component(hass, domain_base_exception, {}) + + # Check the result of the setup + assert not hass.data[setup.DATA_SETUP_DONE] + assert set(hass.data[setup.DATA_SETUP]) == { + domain_bad, + domain_exception, + domain_base_exception, + } + assert set(hass.config.components) == {domain_good} + + # Calling async_set_domains_to_be_loaded again should not create any new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert not hass.data[setup.DATA_SETUP_DONE] async def test_component_setup_with_validation_and_dependency(