Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ad5ce6d52 | |||
| a117a3cba9 | |||
| e2d9ca9cd9 | |||
| a0fb6df5ba | |||
| 04020d5a56 | |||
| f785b17314 | |||
| 6631c57cfb | |||
| bc76dc3c34 | |||
| ea4931ca3a | |||
| dd20204bf0 | |||
| ef46c62bc6 | |||
| 2bb6e03a36 | |||
| 2288f89415 | |||
| e7ab5afc14 | |||
| 4db88dfaff | |||
| 906c95048c | |||
| df38c1b1d7 | |||
| af97bf1c5f | |||
| a7c2d96ecf | |||
| 1b06b4e45b | |||
| b74b9bc360 | |||
| 810689ce66 | |||
| 249d93574a | |||
| e2c59f276a | |||
| 9804e8aa98 | |||
| 53e69af088 | |||
| 1530edbe20 | |||
| 7dbf32d693 | |||
| 49646ad994 | |||
| 1e652db37f | |||
| 88d366b0c5 | |||
| 65147f8d4c | |||
| 52b919101a | |||
| 24fd74d839 | |||
| 2599faa622 | |||
| 3df91cfba5 | |||
| d3fab42c85 | |||
| beb881492a | |||
| 9d7c7f9fcf | |||
| 419307a7c4 | |||
| 409dc4ad48 | |||
| 7704ef95a4 | |||
| 0db07a033b | |||
| 4717eb3142 | |||
| c23f5c9f2c | |||
| 873b078bb3 | |||
| 0dd93a18c5 | |||
| da96e2077b | |||
| 1d69cf11a5 | |||
| adb1fbbbc4 | |||
| 645f2e44b9 | |||
| b3aede611a | |||
| 72a96249b1 | |||
| 80dbce14ec | |||
| 0376f75ee3 | |||
| e58bd62c68 | |||
| 6dbcd130b0 | |||
| 4639f57014 | |||
| 4080455c12 | |||
| df7d518f38 | |||
| 47adfb574f | |||
| 4c5d0c2ec4 | |||
| 4febe43021 | |||
| af13979855 | |||
| d9f2140df3 | |||
| cc80108629 | |||
| 16af76b968 | |||
| 590f0ce61f | |||
| 14059c6df8 | |||
| 268c21addd | |||
| 565fa4ea1f | |||
| 28cd7f2473 | |||
| aceb1b39ba | |||
| 6edf06f8a4 | |||
| 07ae9b15d0 | |||
| d676169b04 | |||
| 24ce3d7daa | |||
| 417e736746 | |||
| bb8d4ca255 | |||
| 375af6cb1c | |||
| 263e0acd3a | |||
| da531d0e4e | |||
| 844e36c8fe | |||
| 9976c07f89 | |||
| 7df9d2e938 | |||
| 52318f5f37 | |||
| b9c2b3f7e3 | |||
| a9ff5b8007 | |||
| 7076ba7c9d | |||
| 5e0088feaa | |||
| f8399b2c0f | |||
| 415fdf4956 | |||
| ad89004189 | |||
| b6afbe4b29 | |||
| 402340955e | |||
| b2a160d926 | |||
| 9840785363 | |||
| a53c92d4b5 | |||
| adc97b6c15 | |||
| 7b2a5d0684 | |||
| acb511d395 | |||
| c025390c6c | |||
| 942fbdedcf | |||
| 3bfb6707e9 | |||
| 5172139579 | |||
| cfb43c7b58 | |||
| 45657ece7c | |||
| f7fe2f2122 | |||
| c75222e63c | |||
| 299250ebec | |||
| ed8e242049 | |||
| 95e4a40ad5 | |||
| e61717ce7a | |||
| 73b6bd8bd3 | |||
| 60774c69cd | |||
| c383b41a12 | |||
| 05a8b773b9 | |||
| 1bee423c22 | |||
| 687afd23bc | |||
| 0020c48a15 | |||
| 760cbcc596 | |||
| da8f4e5b57 | |||
| 5c0659c8df | |||
| 15806c2af6 | |||
| 97d8d16cc5 | |||
| 33435fa36f | |||
| 6fc1cfded9 | |||
| a9d6a42781 | |||
| f2a706ecf7 | |||
| 4a2ae7f6fd | |||
| 771ead9d7b | |||
| 2d5e2aa4b4 | |||
| 6f11524b84 | |||
| 561f319e3b | |||
| 0c9ec4b699 | |||
| cbb2930805 | |||
| aa29a93fbe | |||
| ff4ba553c4 | |||
| 2f101c5054 | |||
| 72e2b835d9 | |||
| 8f6e4cd294 | |||
| bd0edd4996 | |||
| 3f441e7090 | |||
| 253098d79c | |||
| 53ebf84339 | |||
| 7cfbc3eeae | |||
| 8d32531bc1 | |||
| 30d95f37d8 |
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 11
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
HA_SHORT_VERSION: "2025.1"
|
||||
HA_SHORT_VERSION: "2025.2"
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
@@ -76,8 +76,20 @@ jobs:
|
||||
|
||||
# Use C-Extension for SQLAlchemy
|
||||
echo "REQUIRE_SQLALCHEMY_CEXT=1"
|
||||
|
||||
# Add additional pip wheel build constraints
|
||||
echo "PIP_CONSTRAINT=build_constraints.txt"
|
||||
) > .env_file
|
||||
|
||||
- name: Write pip wheel build constraints
|
||||
run: |
|
||||
(
|
||||
# ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf
|
||||
# this caused the numpy builds to fail
|
||||
# https://github.com/scikit-build/ninja-python-distributions/issues/274
|
||||
echo "ninja==1.11.1.1"
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
@@ -86,6 +98,13 @@ jobs:
|
||||
include-hidden-files: true
|
||||
overwrite: true
|
||||
|
||||
- name: Upload build_constraints
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
name: build_constraints
|
||||
path: ./build_constraints.txt
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@v4.5.0
|
||||
with:
|
||||
@@ -123,6 +142,11 @@ jobs:
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
@@ -167,6 +191,11 @@ jobs:
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
with:
|
||||
|
||||
@@ -362,6 +362,7 @@ homeassistant.components.openuv.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.panel_custom.*
|
||||
homeassistant.components.peblar.*
|
||||
|
||||
+10
-8
@@ -1103,8 +1103,10 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
|
||||
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 @alexfp14
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
/tests/components/overkiz/ @imicknl
|
||||
/homeassistant/components/overseerr/ @joostlek
|
||||
/tests/components/overseerr/ @joostlek
|
||||
/homeassistant/components/ovo_energy/ @timmo001
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
@@ -1135,8 +1137,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/plaato/ @JohNan
|
||||
/homeassistant/components/plex/ @jjlawren
|
||||
/tests/components/plex/ @jjlawren
|
||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew @frenck
|
||||
/tests/components/plugwise/ @CoMPaTech @bouwew @frenck
|
||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
||||
/tests/components/plugwise/ @CoMPaTech @bouwew
|
||||
/homeassistant/components/plum_lightpad/ @ColinHarrington @prystupa
|
||||
/tests/components/plum_lightpad/ @ColinHarrington @prystupa
|
||||
/homeassistant/components/point/ @fredrike
|
||||
@@ -1478,8 +1480,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/system_bridge/ @timmo001
|
||||
/homeassistant/components/systemmonitor/ @gjohansson-ST
|
||||
/tests/components/systemmonitor/ @gjohansson-ST
|
||||
/homeassistant/components/tado/ @chiefdragon @erwindouna
|
||||
/tests/components/tado/ @chiefdragon @erwindouna
|
||||
/homeassistant/components/tado/ @erwindouna
|
||||
/tests/components/tado/ @erwindouna
|
||||
/homeassistant/components/tag/ @balloob @dmulcahey
|
||||
/tests/components/tag/ @balloob @dmulcahey
|
||||
/homeassistant/components/tailscale/ @frenck
|
||||
@@ -1573,8 +1575,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/triggercmd/ @rvmey
|
||||
/homeassistant/components/tts/ @home-assistant/core
|
||||
/tests/components/tts/ @home-assistant/core
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver @frenck
|
||||
/tests/components/tuya/ @Tuya @zlinoliver @frenck
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver
|
||||
/tests/components/tuya/ @Tuya @zlinoliver
|
||||
/homeassistant/components/twentemilieu/ @frenck
|
||||
/tests/components/twentemilieu/ @frenck
|
||||
/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen
|
||||
|
||||
@@ -89,7 +89,7 @@ from .helpers import (
|
||||
)
|
||||
from .helpers.dispatcher import async_dispatcher_send_internal
|
||||
from .helpers.storage import get_internal_store_manager
|
||||
from .helpers.system_info import async_get_system_info
|
||||
from .helpers.system_info import async_get_system_info, is_official_image
|
||||
from .helpers.typing import ConfigType
|
||||
from .setup import (
|
||||
# _setup_started is marked as protected to make it clear
|
||||
@@ -106,7 +106,6 @@ from .util.async_ import create_eager_task
|
||||
from .util.hass_dict import HassKey
|
||||
from .util.logging import async_activate_log_queue_handler
|
||||
from .util.package import async_get_user_site, is_docker_env, is_virtual_env
|
||||
from .util.system_info import is_official_image
|
||||
|
||||
with contextlib.suppress(ImportError):
|
||||
# Ensure anyio backend is imported to avoid it being imported in the event loop
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "microsoft",
|
||||
"name": "Microsoft",
|
||||
"integrations": [
|
||||
"azure_data_explorer",
|
||||
"azure_devops",
|
||||
"azure_event_hub",
|
||||
"azure_service_bus",
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.13"]
|
||||
"requirements": ["aioacaia==0.1.11"]
|
||||
}
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"title": "Configure Android apps",
|
||||
"description": "Configure application ID {app_id}",
|
||||
"title": "Configure Android Apps",
|
||||
"description": "Configure application id {app_id}",
|
||||
"data": {
|
||||
"app_name": "Application name",
|
||||
"app_name": "Application Name",
|
||||
"app_id": "Application ID",
|
||||
"app_icon": "Application icon",
|
||||
"app_icon": "Application Icon",
|
||||
"app_delete": "Check to delete this application"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
|
||||
scan_filter: str | None = None
|
||||
all_identifiers: set[str]
|
||||
atv: BaseConfig | None = None
|
||||
atv_identifiers: list[str] | None = None
|
||||
_host: str # host in zeroconf discovery info, should not be accessed by other flows
|
||||
@@ -117,7 +118,6 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new AppleTVConfigFlow."""
|
||||
self.credentials: dict[int, str | None] = {} # Protocol -> credentials
|
||||
self.all_identifiers: set[str] = set()
|
||||
|
||||
@property
|
||||
def device_identifier(self) -> str | None:
|
||||
|
||||
@@ -120,8 +120,6 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
"""Wait for the client to be ready."""
|
||||
|
||||
if not self.data or Attribute.MAC_ADDRESS not in self.data:
|
||||
await self.client.read_mac_address()
|
||||
|
||||
data = await self.client.wait_for_response(
|
||||
FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT
|
||||
)
|
||||
@@ -132,9 +130,12 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
|
||||
return False
|
||||
|
||||
if not self.data or Attribute.THERMOSTAT_MODES not in self.data:
|
||||
await self.client.read_thermostat_iaq_available()
|
||||
if not self.data or Attribute.NAME not in self.data:
|
||||
await self.client.wait_for_response(
|
||||
FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT
|
||||
)
|
||||
|
||||
if not self.data or Attribute.THERMOSTAT_MODES not in self.data:
|
||||
await self.client.wait_for_response(
|
||||
FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT
|
||||
)
|
||||
@@ -143,16 +144,10 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
not self.data
|
||||
or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data
|
||||
):
|
||||
await self.client.read_sensors()
|
||||
|
||||
await self.client.wait_for_response(
|
||||
FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT
|
||||
)
|
||||
|
||||
await self.client.read_thermostat_status()
|
||||
|
||||
await self.client.read_iaq_status()
|
||||
|
||||
await ready_callback(True)
|
||||
|
||||
return True
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyaprilaire"],
|
||||
"requirements": ["pyaprilaire==0.7.7"]
|
||||
"requirements": ["pyaprilaire==0.7.4"]
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ class AudioBuffer:
|
||||
class VoiceCommandSegmenter:
|
||||
"""Segments an audio stream into voice commands."""
|
||||
|
||||
speech_seconds: float = 0.3
|
||||
speech_seconds: float = 0.1
|
||||
"""Seconds of speech before voice command has started."""
|
||||
|
||||
command_seconds: float = 1.0
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"invalid_unique_id": "Impossible to determine a valid unique ID for the device",
|
||||
"no_unique_id": "A device without a valid unique ID is already configured. Configuration of multiple instances is not possible"
|
||||
"invalid_unique_id": "Impossible to determine a valid unique id for the device",
|
||||
"no_unique_id": "A device without a valid unique id is already configured. Configuration of multiple instance is not possible"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -42,7 +42,7 @@
|
||||
"consider_home": "Seconds to wait before considering a device away",
|
||||
"track_unknown": "Track unknown / unnamed devices",
|
||||
"interface": "The interface that you want statistics from (e.g. eth0, eth1 etc)",
|
||||
"dnsmasq": "The location of the dnsmasq.leases file in the router",
|
||||
"dnsmasq": "The location in the router of the dnsmasq.leases files",
|
||||
"require_ip": "Devices must have IP (for access point mode)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ from .manager import (
|
||||
BackupManager,
|
||||
BackupPlatformProtocol,
|
||||
BackupReaderWriter,
|
||||
BackupReaderWriterError,
|
||||
CoreBackupReaderWriter,
|
||||
CreateBackupEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
NewBackup,
|
||||
WrittenBackup,
|
||||
@@ -41,10 +39,8 @@ __all__ = [
|
||||
"BackupAgentPlatformProtocol",
|
||||
"BackupPlatformProtocol",
|
||||
"BackupReaderWriter",
|
||||
"BackupReaderWriterError",
|
||||
"CreateBackupEvent",
|
||||
"Folder",
|
||||
"IncorrectPasswordError",
|
||||
"LocalBackupAgent",
|
||||
"NewBackup",
|
||||
"WrittenBackup",
|
||||
|
||||
@@ -7,7 +7,6 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
from enum import StrEnum
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Self, TypedDict
|
||||
|
||||
from cronsim import CronSim
|
||||
@@ -18,7 +17,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import LOGGER
|
||||
from .models import BackupManagerError, Folder
|
||||
from .models import Folder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import BackupManager, ManagerBackup
|
||||
@@ -29,10 +28,6 @@ if TYPE_CHECKING:
|
||||
CRON_PATTERN_DAILY = "45 4 * * *"
|
||||
CRON_PATTERN_WEEKLY = "45 4 * * {}"
|
||||
|
||||
# Randomize the start time of the backup by up to 60 minutes to avoid
|
||||
# all backups running at the same time.
|
||||
BACKUP_START_TIME_JITTER = 60 * 60
|
||||
|
||||
|
||||
class StoredBackupConfig(TypedDict):
|
||||
"""Represent the stored backup config."""
|
||||
@@ -129,7 +124,6 @@ class BackupConfig:
|
||||
def load(self, stored_config: StoredBackupConfig) -> None:
|
||||
"""Load config."""
|
||||
self.data = BackupConfigData.from_dict(stored_config)
|
||||
self.data.retention.apply(self._manager)
|
||||
self.data.schedule.apply(self._manager)
|
||||
|
||||
async def update(
|
||||
@@ -166,13 +160,8 @@ class RetentionConfig:
|
||||
def apply(self, manager: BackupManager) -> None:
|
||||
"""Apply backup retention configuration."""
|
||||
if self.days is not None:
|
||||
LOGGER.debug(
|
||||
"Scheduling next automatic delete of backups older than %s in 1 day",
|
||||
self.days,
|
||||
)
|
||||
self._schedule_next(manager)
|
||||
else:
|
||||
LOGGER.debug("Unscheduling next automatic delete")
|
||||
self._unschedule_next(manager)
|
||||
|
||||
def to_dict(self) -> StoredRetentionConfig:
|
||||
@@ -329,13 +318,11 @@ class BackupSchedule:
|
||||
password=config_data.create_backup.password,
|
||||
with_automatic_settings=True,
|
||||
)
|
||||
except BackupManagerError as err:
|
||||
LOGGER.error("Error creating backup: %s", err)
|
||||
except Exception: # noqa: BLE001
|
||||
# another more specific exception will be added
|
||||
# and handled in the future
|
||||
LOGGER.exception("Unexpected error creating automatic backup")
|
||||
|
||||
next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
|
||||
LOGGER.debug("Scheduling next automatic backup at %s", next_time)
|
||||
manager.remove_next_backup_event = async_track_point_in_time(
|
||||
manager.hass, _create_backup, next_time
|
||||
)
|
||||
|
||||
@@ -46,11 +46,15 @@ from .const import (
|
||||
EXCLUDE_FROM_BACKUP,
|
||||
LOGGER,
|
||||
)
|
||||
from .models import AgentBackup, BackupManagerError, Folder
|
||||
from .models import AgentBackup, Folder
|
||||
from .store import BackupStore
|
||||
from .util import make_backup_dir, read_backup, validate_password
|
||||
|
||||
|
||||
class IncorrectPasswordError(HomeAssistantError):
|
||||
"""Raised when the password is incorrect."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class NewBackup:
|
||||
"""New backup class."""
|
||||
@@ -241,14 +245,6 @@ class BackupReaderWriter(abc.ABC):
|
||||
"""Restore a backup."""
|
||||
|
||||
|
||||
class BackupReaderWriterError(HomeAssistantError):
|
||||
"""Backup reader/writer error."""
|
||||
|
||||
|
||||
class IncorrectPasswordError(BackupReaderWriterError):
|
||||
"""Raised when the password is incorrect."""
|
||||
|
||||
|
||||
class BackupManager:
|
||||
"""Define the format that backup managers can have."""
|
||||
|
||||
@@ -377,9 +373,7 @@ class BackupManager:
|
||||
)
|
||||
for result in pre_backup_results:
|
||||
if isinstance(result, Exception):
|
||||
raise BackupManagerError(
|
||||
f"Error during pre-backup: {result}"
|
||||
) from result
|
||||
raise result
|
||||
|
||||
async def async_post_backup_actions(self) -> None:
|
||||
"""Perform post backup actions."""
|
||||
@@ -392,9 +386,7 @@ class BackupManager:
|
||||
)
|
||||
for result in post_backup_results:
|
||||
if isinstance(result, Exception):
|
||||
raise BackupManagerError(
|
||||
f"Error during post-backup: {result}"
|
||||
) from result
|
||||
raise result
|
||||
|
||||
async def load_platforms(self) -> None:
|
||||
"""Load backup platforms."""
|
||||
@@ -430,22 +422,11 @@ class BackupManager:
|
||||
return_exceptions=True,
|
||||
)
|
||||
for idx, result in enumerate(sync_backup_results):
|
||||
if isinstance(result, BackupReaderWriterError):
|
||||
# writer errors will affect all agents
|
||||
# no point in continuing
|
||||
raise BackupManagerError(str(result)) from result
|
||||
if isinstance(result, BackupAgentError):
|
||||
LOGGER.error("Error uploading to %s: %s", agent_ids[idx], result)
|
||||
agent_errors[agent_ids[idx]] = result
|
||||
continue
|
||||
if isinstance(result, Exception):
|
||||
# trap bugs from agents
|
||||
agent_errors[agent_ids[idx]] = result
|
||||
LOGGER.error("Unexpected error: %s", result, exc_info=result)
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
|
||||
LOGGER.exception(
|
||||
"Error during backup upload - %s", result, exc_info=result
|
||||
)
|
||||
return agent_errors
|
||||
|
||||
async def async_get_backups(
|
||||
@@ -468,7 +449,7 @@ class BackupManager:
|
||||
agent_errors[agent_ids[idx]] = result
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result # unexpected error
|
||||
raise result
|
||||
for agent_backup in result:
|
||||
if (backup_id := agent_backup.backup_id) not in backups:
|
||||
if known_backup := self.known_backups.get(backup_id):
|
||||
@@ -518,7 +499,7 @@ class BackupManager:
|
||||
agent_errors[agent_ids[idx]] = result
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result # unexpected error
|
||||
raise result
|
||||
if not result:
|
||||
continue
|
||||
if backup is None:
|
||||
@@ -582,7 +563,7 @@ class BackupManager:
|
||||
agent_errors[agent_ids[idx]] = result
|
||||
continue
|
||||
if isinstance(result, BaseException):
|
||||
raise result # unexpected error
|
||||
raise result
|
||||
|
||||
if not agent_errors:
|
||||
self.known_backups.remove(backup_id)
|
||||
@@ -597,7 +578,7 @@ class BackupManager:
|
||||
) -> None:
|
||||
"""Receive and store a backup file from upload."""
|
||||
if self.state is not BackupManagerState.IDLE:
|
||||
raise BackupManagerError(f"Backup manager busy: {self.state}")
|
||||
raise HomeAssistantError(f"Backup manager busy: {self.state}")
|
||||
self.async_on_backup_event(
|
||||
ReceiveBackupEvent(stage=None, state=ReceiveBackupState.IN_PROGRESS)
|
||||
)
|
||||
@@ -671,7 +652,6 @@ class BackupManager:
|
||||
include_homeassistant=include_homeassistant,
|
||||
name=name,
|
||||
password=password,
|
||||
raise_task_error=True,
|
||||
with_automatic_settings=with_automatic_settings,
|
||||
)
|
||||
assert self._backup_finish_task
|
||||
@@ -689,12 +669,11 @@ class BackupManager:
|
||||
include_homeassistant: bool,
|
||||
name: str | None,
|
||||
password: str | None,
|
||||
raise_task_error: bool = False,
|
||||
with_automatic_settings: bool = False,
|
||||
) -> NewBackup:
|
||||
"""Initiate generating a backup."""
|
||||
if self.state is not BackupManagerState.IDLE:
|
||||
raise BackupManagerError(f"Backup manager busy: {self.state}")
|
||||
raise HomeAssistantError(f"Backup manager busy: {self.state}")
|
||||
|
||||
if with_automatic_settings:
|
||||
self.config.data.last_attempted_automatic_backup = dt_util.now()
|
||||
@@ -713,7 +692,6 @@ class BackupManager:
|
||||
include_homeassistant=include_homeassistant,
|
||||
name=name,
|
||||
password=password,
|
||||
raise_task_error=raise_task_error,
|
||||
with_automatic_settings=with_automatic_settings,
|
||||
)
|
||||
except Exception:
|
||||
@@ -736,81 +714,57 @@ class BackupManager:
|
||||
include_homeassistant: bool,
|
||||
name: str | None,
|
||||
password: str | None,
|
||||
raise_task_error: bool,
|
||||
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 := [
|
||||
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
|
||||
]:
|
||||
raise BackupManagerError(f"Invalid agents selected: {invalid_agents}")
|
||||
raise HomeAssistantError("At least one agent must be selected")
|
||||
if any(agent_id not in self.backup_agents for agent_id in agent_ids):
|
||||
raise HomeAssistantError("Invalid agent selected")
|
||||
if include_all_addons and include_addons:
|
||||
raise BackupManagerError(
|
||||
raise HomeAssistantError(
|
||||
"Cannot include all addons and specify specific addons"
|
||||
)
|
||||
|
||||
backup_name = (
|
||||
name
|
||||
or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}"
|
||||
or f"{"Automatic" if with_automatic_settings else "Custom"} {HAVERSION}"
|
||||
)
|
||||
|
||||
try:
|
||||
(
|
||||
new_backup,
|
||||
self._backup_task,
|
||||
) = await self._reader_writer.async_create_backup(
|
||||
agent_ids=agent_ids,
|
||||
backup_name=backup_name,
|
||||
extra_metadata={
|
||||
"instance_id": await instance_id.async_get(self.hass),
|
||||
"with_automatic_settings": with_automatic_settings,
|
||||
},
|
||||
include_addons=include_addons,
|
||||
include_all_addons=include_all_addons,
|
||||
include_database=include_database,
|
||||
include_folders=include_folders,
|
||||
include_homeassistant=include_homeassistant,
|
||||
on_progress=self.async_on_backup_event,
|
||||
password=password,
|
||||
)
|
||||
except BackupReaderWriterError as err:
|
||||
raise BackupManagerError(str(err)) from err
|
||||
|
||||
backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
|
||||
new_backup, self._backup_task = await self._reader_writer.async_create_backup(
|
||||
agent_ids=agent_ids,
|
||||
backup_name=backup_name,
|
||||
extra_metadata={
|
||||
"instance_id": await instance_id.async_get(self.hass),
|
||||
"with_automatic_settings": with_automatic_settings,
|
||||
},
|
||||
include_addons=include_addons,
|
||||
include_all_addons=include_all_addons,
|
||||
include_database=include_database,
|
||||
include_folders=include_folders,
|
||||
include_homeassistant=include_homeassistant,
|
||||
on_progress=self.async_on_backup_event,
|
||||
password=password,
|
||||
)
|
||||
self._backup_finish_task = self.hass.async_create_task(
|
||||
self._async_finish_backup(agent_ids, with_automatic_settings),
|
||||
name="backup_manager_finish_backup",
|
||||
)
|
||||
if not raise_task_error:
|
||||
|
||||
def log_finish_task_error(task: asyncio.Task[None]) -> None:
|
||||
if task.done() and not task.cancelled() and (err := task.exception()):
|
||||
if isinstance(err, BackupManagerError):
|
||||
LOGGER.error("Error creating backup: %s", err)
|
||||
else:
|
||||
LOGGER.error("Unexpected error: %s", err, exc_info=err)
|
||||
|
||||
backup_finish_task.add_done_callback(log_finish_task_error)
|
||||
|
||||
return new_backup
|
||||
|
||||
async def _async_finish_backup(
|
||||
self, agent_ids: list[str], with_automatic_settings: bool
|
||||
) -> None:
|
||||
"""Finish a backup."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._backup_task is not None
|
||||
backup_success = False
|
||||
try:
|
||||
written_backup = await self._backup_task
|
||||
except Exception as err:
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.debug("Generating backup failed", exc_info=err)
|
||||
self.async_on_backup_event(
|
||||
CreateBackupEvent(stage=None, state=CreateBackupState.FAILED)
|
||||
)
|
||||
if with_automatic_settings:
|
||||
self._update_issue_backup_failed()
|
||||
|
||||
if isinstance(err, BackupReaderWriterError):
|
||||
raise BackupManagerError(str(err)) from err
|
||||
raise # unexpected error
|
||||
else:
|
||||
LOGGER.debug(
|
||||
"Generated new backup with backup_id %s, uploading to agents %s",
|
||||
@@ -823,40 +777,28 @@ class BackupManager:
|
||||
state=CreateBackupState.IN_PROGRESS,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
agent_errors = await self._async_upload_backup(
|
||||
backup=written_backup.backup,
|
||||
agent_ids=agent_ids,
|
||||
open_stream=written_backup.open_stream,
|
||||
)
|
||||
finally:
|
||||
await written_backup.release_stream()
|
||||
self.known_backups.add(written_backup.backup, agent_errors)
|
||||
if not agent_errors:
|
||||
if with_automatic_settings:
|
||||
# create backup was successful, update last_completed_automatic_backup
|
||||
self.config.data.last_completed_automatic_backup = dt_util.now()
|
||||
self.store.save()
|
||||
backup_success = True
|
||||
|
||||
agent_errors = await self._async_upload_backup(
|
||||
backup=written_backup.backup,
|
||||
agent_ids=agent_ids,
|
||||
open_stream=written_backup.open_stream,
|
||||
)
|
||||
await written_backup.release_stream()
|
||||
if with_automatic_settings:
|
||||
# create backup was successful, update last_completed_automatic_backup
|
||||
self.config.data.last_completed_automatic_backup = dt_util.now()
|
||||
self.store.save()
|
||||
self._update_issue_after_agent_upload(agent_errors)
|
||||
self.known_backups.add(written_backup.backup, agent_errors)
|
||||
|
||||
# delete old backups more numerous than copies
|
||||
# try this regardless of agent errors above
|
||||
await delete_backups_exceeding_configured_count(self)
|
||||
|
||||
self.async_on_backup_event(
|
||||
CreateBackupEvent(stage=None, state=CreateBackupState.COMPLETED)
|
||||
)
|
||||
finally:
|
||||
self._backup_task = None
|
||||
self._backup_finish_task = None
|
||||
self.async_on_backup_event(
|
||||
CreateBackupEvent(
|
||||
stage=None,
|
||||
state=CreateBackupState.COMPLETED
|
||||
if backup_success
|
||||
else CreateBackupState.FAILED,
|
||||
)
|
||||
)
|
||||
self.async_on_backup_event(IdleEvent())
|
||||
|
||||
async def async_restore_backup(
|
||||
@@ -872,7 +814,7 @@ class BackupManager:
|
||||
) -> None:
|
||||
"""Initiate restoring a backup."""
|
||||
if self.state is not BackupManagerState.IDLE:
|
||||
raise BackupManagerError(f"Backup manager busy: {self.state}")
|
||||
raise HomeAssistantError(f"Backup manager busy: {self.state}")
|
||||
|
||||
self.async_on_backup_event(
|
||||
RestoreBackupEvent(stage=None, state=RestoreBackupState.IN_PROGRESS)
|
||||
@@ -887,9 +829,6 @@ class BackupManager:
|
||||
restore_folders=restore_folders,
|
||||
restore_homeassistant=restore_homeassistant,
|
||||
)
|
||||
self.async_on_backup_event(
|
||||
RestoreBackupEvent(stage=None, state=RestoreBackupState.COMPLETED)
|
||||
)
|
||||
except Exception:
|
||||
self.async_on_backup_event(
|
||||
RestoreBackupEvent(stage=None, state=RestoreBackupState.FAILED)
|
||||
@@ -912,7 +851,7 @@ class BackupManager:
|
||||
"""Initiate restoring a backup."""
|
||||
agent = self.backup_agents[agent_id]
|
||||
if not await agent.async_get_backup(backup_id):
|
||||
raise BackupManagerError(
|
||||
raise HomeAssistantError(
|
||||
f"Backup {backup_id} not found in agent {agent_id}"
|
||||
)
|
||||
|
||||
@@ -1085,11 +1024,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
backup_id = _generate_backup_id(date_str, backup_name)
|
||||
|
||||
if include_addons or include_all_addons or include_folders:
|
||||
raise BackupReaderWriterError(
|
||||
raise HomeAssistantError(
|
||||
"Addons and folders are not supported by core backup"
|
||||
)
|
||||
if not include_homeassistant:
|
||||
raise BackupReaderWriterError("Home Assistant must be included in backup")
|
||||
raise HomeAssistantError("Home Assistant must be included in backup")
|
||||
|
||||
backup_task = self._hass.async_create_task(
|
||||
self._async_create_backup(
|
||||
@@ -1160,13 +1099,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
password,
|
||||
local_agent_tar_file_path,
|
||||
)
|
||||
except (BackupManagerError, OSError, tarfile.TarError, ValueError) as err:
|
||||
# BackupManagerError from async_pre_backup_actions
|
||||
# OSError from file operations
|
||||
# TarError from tarfile
|
||||
# ValueError from json_bytes
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
else:
|
||||
backup = AgentBackup(
|
||||
addons=[],
|
||||
backup_id=backup_id,
|
||||
@@ -1184,15 +1116,12 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
async_add_executor_job = self._hass.async_add_executor_job
|
||||
|
||||
async def send_backup() -> AsyncIterator[bytes]:
|
||||
f = await async_add_executor_job(tar_file_path.open, "rb")
|
||||
try:
|
||||
f = await async_add_executor_job(tar_file_path.open, "rb")
|
||||
try:
|
||||
while chunk := await async_add_executor_job(f.read, 2**20):
|
||||
yield chunk
|
||||
finally:
|
||||
await async_add_executor_job(f.close)
|
||||
except OSError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
while chunk := await async_add_executor_job(f.read, 2**20):
|
||||
yield chunk
|
||||
finally:
|
||||
await async_add_executor_job(f.close)
|
||||
|
||||
async def open_backup() -> AsyncIterator[bytes]:
|
||||
return send_backup()
|
||||
@@ -1200,20 +1129,14 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
async def remove_backup() -> None:
|
||||
if local_agent_tar_file_path:
|
||||
return
|
||||
try:
|
||||
await async_add_executor_job(tar_file_path.unlink, True)
|
||||
except OSError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
await async_add_executor_job(tar_file_path.unlink, True)
|
||||
|
||||
return WrittenBackup(
|
||||
backup=backup, open_stream=open_backup, release_stream=remove_backup
|
||||
)
|
||||
finally:
|
||||
# Inform integrations the backup is done
|
||||
try:
|
||||
await manager.async_post_backup_actions()
|
||||
except BackupManagerError as err:
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
await manager.async_post_backup_actions()
|
||||
|
||||
def _mkdir_and_generate_backup_contents(
|
||||
self,
|
||||
@@ -1283,7 +1206,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
if self._local_agent_id in agent_ids:
|
||||
local_agent = manager.local_backup_agents[self._local_agent_id]
|
||||
tar_file_path = local_agent.get_backup_path(backup.backup_id)
|
||||
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
|
||||
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
|
||||
else:
|
||||
tar_file_path = temp_file
|
||||
@@ -1327,11 +1249,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
"""
|
||||
|
||||
if restore_addons or restore_folders:
|
||||
raise BackupReaderWriterError(
|
||||
raise HomeAssistantError(
|
||||
"Addons and folders are not supported in core restore"
|
||||
)
|
||||
if not restore_homeassistant and not restore_database:
|
||||
raise BackupReaderWriterError(
|
||||
raise HomeAssistantError(
|
||||
"Home Assistant or database must be included in restore"
|
||||
)
|
||||
|
||||
@@ -1376,7 +1298,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
)
|
||||
|
||||
await self._hass.async_add_executor_job(_write_restore_file)
|
||||
await self._hass.services.async_call("homeassistant", "restart", blocking=True)
|
||||
await self._hass.services.async_call("homeassistant", "restart", {})
|
||||
|
||||
|
||||
def _generate_backup_id(date: str, name: str) -> str:
|
||||
|
||||
@@ -6,8 +6,6 @@ from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any, Self
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AddonInfo:
|
||||
@@ -69,7 +67,3 @@ class AgentBackup:
|
||||
protected=data["protected"],
|
||||
size=data["size"],
|
||||
)
|
||||
|
||||
|
||||
class BackupManagerError(HomeAssistantError):
|
||||
"""Backup manager error."""
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
},
|
||||
"automatic_backup_failed_upload_agents": {
|
||||
"title": "Automatic backup could not be uploaded to the configured locations",
|
||||
"description": "The automatic backup could not be uploaded to the configured locations {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
"title": "Automatic backup could not be uploaded to agents",
|
||||
"description": "The automatic backup could not be uploaded to agents {failed_agents}. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
"bluetooth-auto-recovery==1.4.2",
|
||||
"bluetooth-data-tools==1.20.0",
|
||||
"dbus-fast==2.24.3",
|
||||
"habluetooth==3.7.0"
|
||||
"habluetooth==3.6.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bring",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bring_api"],
|
||||
"requirements": ["bring-api==0.9.1"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up {name}?"
|
||||
"description": "Do you want to setup {name}?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"description": "Reconfigure your Cambridge Audio Streamer.",
|
||||
@@ -28,7 +28,7 @@
|
||||
"cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect."
|
||||
},
|
||||
"abort": {
|
||||
"wrong_device": "This Cambridge Audio device does not match the existing device ID. Please make sure you entered the correct IP address.",
|
||||
"wrong_device": "This Cambridge Audio device does not match the existing device id. Please make sure you entered the correct IP address.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
|
||||
@@ -516,19 +516,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def supported_features_compat(self) -> CameraEntityFeature:
|
||||
"""Return the supported features as CameraEntityFeature.
|
||||
|
||||
Remove this compatibility shim in 2025.1 or later.
|
||||
"""
|
||||
features = self.supported_features
|
||||
if type(features) is int: # noqa: E721
|
||||
new_features = CameraEntityFeature(features)
|
||||
self._report_deprecated_supported_features_values(new_features)
|
||||
return new_features
|
||||
return features
|
||||
|
||||
@cached_property
|
||||
def is_recording(self) -> bool:
|
||||
"""Return true if the device is recording."""
|
||||
@@ -582,7 +569,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
self._deprecate_attr_frontend_stream_type_logged = True
|
||||
return self._attr_frontend_stream_type
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
if CameraEntityFeature.STREAM not in self.supported_features:
|
||||
return None
|
||||
if (
|
||||
self._webrtc_provider
|
||||
@@ -811,9 +798,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
self.__supports_stream = (
|
||||
self.supported_features_compat & CameraEntityFeature.STREAM
|
||||
)
|
||||
self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM
|
||||
await self.async_refresh_providers(write_state=False)
|
||||
|
||||
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
||||
@@ -853,7 +838,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
||||
) -> _T | None:
|
||||
"""Get first provider that supports this camera."""
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
if CameraEntityFeature.STREAM not in self.supported_features:
|
||||
return None
|
||||
|
||||
return await fn(self.hass, self)
|
||||
@@ -911,7 +896,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def camera_capabilities(self) -> CameraCapabilities:
|
||||
"""Return the camera capabilities."""
|
||||
frontend_stream_types = set()
|
||||
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||
if CameraEntityFeature.STREAM in self.supported_features:
|
||||
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
|
||||
# The camera has a native WebRTC implementation
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
@@ -931,8 +916,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features_compat
|
||||
& CameraEntityFeature.STREAM
|
||||
supports_stream := self.supported_features & CameraEntityFeature.STREAM
|
||||
):
|
||||
self.__supports_stream = supports_stream
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import configparser
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
@@ -129,7 +129,7 @@ class ChromecastInfo:
|
||||
class ChromeCastZeroconf:
|
||||
"""Class to hold a zeroconf instance."""
|
||||
|
||||
__zconf: zeroconf.HaZeroconf | None = None
|
||||
__zconf: ClassVar[zeroconf.HaZeroconf | None] = None
|
||||
|
||||
@classmethod
|
||||
def set_zeroconf(cls, zconf: zeroconf.HaZeroconf) -> None:
|
||||
|
||||
@@ -240,6 +240,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"preset_mode",
|
||||
"preset_modes",
|
||||
"is_aux_heat",
|
||||
"is_on",
|
||||
"fan_mode",
|
||||
"fan_modes",
|
||||
"swing_mode",
|
||||
@@ -280,6 +281,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_hvac_mode: HVACMode | None
|
||||
_attr_hvac_modes: list[HVACMode]
|
||||
_attr_is_aux_heat: bool | None
|
||||
_attr_is_on: bool | None
|
||||
_attr_max_humidity: float = DEFAULT_MAX_HUMIDITY
|
||||
_attr_max_temp: float
|
||||
_attr_min_humidity: float = DEFAULT_MIN_HUMIDITY
|
||||
@@ -352,11 +354,33 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
hvac_mode = self.hvac_mode
|
||||
if hvac_mode is None:
|
||||
return None
|
||||
if hasattr(self, "_attr_is_on") and self._attr_is_on is False:
|
||||
return HVACMode.OFF.value
|
||||
# Support hvac_mode as string for custom integration backwards compatibility
|
||||
if not isinstance(hvac_mode, HVACMode):
|
||||
return HVACMode(hvac_mode).value # type: ignore[unreachable]
|
||||
return hvac_mode.value
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the climate is turned on.
|
||||
|
||||
The climate's on/off state can be be controlled independently
|
||||
from the hvac_action and hvac_mode if the _attr_is_on attribute is set.
|
||||
|
||||
If the _attr_is_on attribute is set, then return that value.
|
||||
Otherwise, return True if hvac_action is not None and not HVACAction.OFF.
|
||||
Return None if hvac_action is None,
|
||||
otherwise return True if hvac_mode is not HVACMode.OFF.
|
||||
"""
|
||||
if hasattr(self, "_attr_is_on"):
|
||||
return self._attr_is_on
|
||||
if self.hvac_action is not None:
|
||||
return self.hvac_action != HVACAction.OFF
|
||||
if self.hvac_mode is None:
|
||||
return None
|
||||
return self.hvac_mode != HVACMode.OFF
|
||||
|
||||
@property
|
||||
def precision(self) -> float:
|
||||
"""Return the precision of the system."""
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
from typing import Any, Self
|
||||
|
||||
from aiohttp import ClientError, ClientTimeout, StreamReader
|
||||
@@ -26,11 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from .client import CloudClient
|
||||
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_STORAGE_BACKUP = "backup"
|
||||
_RETRY_LIMIT = 5
|
||||
_RETRY_SECONDS_MIN = 60
|
||||
_RETRY_SECONDS_MAX = 600
|
||||
|
||||
|
||||
async def _b64md5(stream: AsyncIterator[bytes]) -> str:
|
||||
@@ -143,55 +136,13 @@ class CloudBackupAgent(BackupAgent):
|
||||
raise BackupAgentError("Failed to get download details") from err
|
||||
|
||||
try:
|
||||
resp = await self._cloud.websession.get(
|
||||
details["url"],
|
||||
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
|
||||
)
|
||||
|
||||
resp = await self._cloud.websession.get(details["url"])
|
||||
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
|
||||
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
@@ -208,34 +159,29 @@ class CloudBackupAgent(BackupAgent):
|
||||
raise BackupAgentError("Cloud backups must be protected")
|
||||
|
||||
base64md5hash = await _b64md5(await open_stream())
|
||||
filename = self._get_backup_filename()
|
||||
metadata = backup.as_dict()
|
||||
size = backup.size
|
||||
|
||||
tries = 1
|
||||
while tries <= _RETRY_LIMIT:
|
||||
try:
|
||||
await self._async_do_upload_backup(
|
||||
open_stream=open_stream,
|
||||
filename=filename,
|
||||
base64md5hash=base64md5hash,
|
||||
metadata=metadata,
|
||||
size=size,
|
||||
)
|
||||
break
|
||||
except BackupAgentError as err:
|
||||
if tries == _RETRY_LIMIT:
|
||||
raise
|
||||
tries += 1
|
||||
retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
|
||||
_LOGGER.info(
|
||||
"Failed to upload backup, retrying (%s/%s) in %ss: %s",
|
||||
tries,
|
||||
_RETRY_LIMIT,
|
||||
retry_timer,
|
||||
err,
|
||||
)
|
||||
await asyncio.sleep(retry_timer)
|
||||
try:
|
||||
details = await async_files_upload_details(
|
||||
self._cloud,
|
||||
storage_type=_STORAGE_BACKUP,
|
||||
filename=self._get_backup_filename(),
|
||||
metadata=backup.as_dict(),
|
||||
size=backup.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(backup.size)},
|
||||
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
|
||||
)
|
||||
upload_status.raise_for_status()
|
||||
except (TimeoutError, ClientError) as err:
|
||||
raise BackupAgentError("Failed to upload backup") from err
|
||||
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
@@ -262,7 +208,6 @@ class CloudBackupAgent(BackupAgent):
|
||||
"""List backups."""
|
||||
try:
|
||||
backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP)
|
||||
_LOGGER.debug("Cloud backups: %s", backups)
|
||||
except (ClientError, CloudError) as err:
|
||||
raise BackupAgentError("Failed to list backups") from err
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["numpy==2.2.0"]
|
||||
"requirements": ["numpy==2.2.1"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.1.0", "home-assistant-intents==2025.1.1"]
|
||||
"requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.20"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
|
||||
from cookidoo_api import Cookidoo, CookidooConfig, CookidooLocalizationConfig
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_COUNTRY,
|
||||
@@ -16,23 +16,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.TODO]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
|
||||
"""Set up Cookidoo from a config entry."""
|
||||
|
||||
localizations = await get_localization_options(
|
||||
country=entry.data[CONF_COUNTRY].lower(),
|
||||
language=entry.data[CONF_LANGUAGE],
|
||||
)
|
||||
|
||||
cookidoo = Cookidoo(
|
||||
async_get_clientsession(hass),
|
||||
CookidooConfig(
|
||||
email=entry.data[CONF_EMAIL],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
localization=localizations[0],
|
||||
localization=CookidooLocalizationConfig(
|
||||
country_code=entry.data[CONF_COUNTRY].lower(),
|
||||
language=entry.data[CONF_LANGUAGE],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Support for Cookidoo buttons."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cookidoo_api import Cookidoo, CookidooException
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
from .entity import CookidooBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CookidooButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes cookidoo button entity."""
|
||||
|
||||
press_fn: Callable[[Cookidoo], Awaitable[None]]
|
||||
|
||||
|
||||
TODO_CLEAR = CookidooButtonEntityDescription(
|
||||
key="todo_clear",
|
||||
translation_key="todo_clear",
|
||||
press_fn=lambda client: client.clear_shopping_list(),
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CookidooConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Cookidoo button entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities([CookidooButton(coordinator, TODO_CLEAR)])
|
||||
|
||||
|
||||
class CookidooButton(CookidooBaseEntity, ButtonEntity):
|
||||
"""Defines an Cookidoo button."""
|
||||
|
||||
entity_description: CookidooButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CookidooDataUpdateCoordinator,
|
||||
description: CookidooButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize cookidoo button."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator.cookidoo)
|
||||
except CookidooException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="button_clear_todo_failed",
|
||||
) from e
|
||||
await self.coordinator.async_refresh()
|
||||
@@ -10,6 +10,7 @@ from cookidoo_api import (
|
||||
Cookidoo,
|
||||
CookidooAuthException,
|
||||
CookidooConfig,
|
||||
CookidooLocalizationConfig,
|
||||
CookidooRequestException,
|
||||
get_country_options,
|
||||
get_localization_options,
|
||||
@@ -218,19 +219,18 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
data_input[CONF_LANGUAGE] = (
|
||||
await get_localization_options(country=data_input[CONF_COUNTRY].lower())
|
||||
)[0].language # Pick any language to test login
|
||||
|
||||
localizations = await get_localization_options(
|
||||
country=data_input[CONF_COUNTRY].lower(),
|
||||
language=data_input[CONF_LANGUAGE],
|
||||
)
|
||||
)[0] # Pick any language to test login
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
cookidoo = Cookidoo(
|
||||
async_get_clientsession(self.hass),
|
||||
session,
|
||||
CookidooConfig(
|
||||
email=data_input[CONF_EMAIL],
|
||||
password=data_input[CONF_PASSWORD],
|
||||
localization=localizations[0],
|
||||
localization=CookidooLocalizationConfig(
|
||||
country_code=data_input[CONF_COUNTRY].lower(),
|
||||
language=data_input[CONF_LANGUAGE],
|
||||
),
|
||||
),
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"todo_clear": {
|
||||
"default": "mdi:cart-off"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"ingredient_list": {
|
||||
"default": "mdi:cart-plus"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/cookidoo",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["cookidoo_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["cookidoo-api==0.12.2"]
|
||||
"requirements": ["cookidoo-api==0.10.0"]
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"todo_clear": {
|
||||
"name": "Clear shopping list and additional purchases"
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
"ingredient_list": {
|
||||
"name": "Shopping list"
|
||||
@@ -58,6 +63,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"button_clear_todo_failed": {
|
||||
"message": "Failed to clear all items from the Cookidoo shopping list"
|
||||
},
|
||||
"todo_save_item_failed": {
|
||||
"message": "Failed to save {name} to Cookidoo shopping list"
|
||||
},
|
||||
|
||||
@@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def supported_features(self) -> CoverEntityFeature:
|
||||
"""Flag supported features."""
|
||||
if (features := self._attr_supported_features) is not None:
|
||||
if type(features) is int: # noqa: E721
|
||||
new_features = CoverEntityFeature(features)
|
||||
self._report_deprecated_supported_features_values(new_features)
|
||||
return new_features
|
||||
return features
|
||||
|
||||
supported_features = (
|
||||
|
||||
@@ -266,7 +266,7 @@ class DeconzBaseLight[_LightDeviceT: Group | Light](
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the CT color value."""
|
||||
if self._device.color_temp is None or self._device.color_temp == 0:
|
||||
if self._device.color_temp is None:
|
||||
return None
|
||||
return color_temperature_mired_to_kelvin(self._device.color_temp)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"services": {
|
||||
"get_command": {
|
||||
"name": "Get command",
|
||||
"description": "Send sa generic HTTP get command.",
|
||||
"description": "Sends a generic HTTP get command.",
|
||||
"fields": {
|
||||
"command": {
|
||||
"name": "Command",
|
||||
|
||||
@@ -57,11 +57,11 @@
|
||||
"services": {
|
||||
"get_gas_prices": {
|
||||
"name": "Get gas prices",
|
||||
"description": "Request gas prices from easyEnergy.",
|
||||
"description": "Requests gas prices from easyEnergy.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "Config Entry",
|
||||
"description": "The config entry to use for this service."
|
||||
"description": "The configuration entry to use for this action."
|
||||
},
|
||||
"incl_vat": {
|
||||
"name": "VAT Included",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"get_energy_usage_prices": {
|
||||
"name": "Get energy usage prices",
|
||||
"description": "Request usage energy prices from easyEnergy.",
|
||||
"description": "Requests usage energy prices from easyEnergy.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"get_energy_return_prices": {
|
||||
"name": "Get energy return prices",
|
||||
"description": "Request return energy prices from easyEnergy.",
|
||||
"description": "Requests return energy prices from easyEnergy.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==10.1.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==10.0.1"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ rules:
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: >
|
||||
|
||||
@@ -49,7 +49,7 @@ class ElkBinarySensor(ElkAttachedEntity, BinarySensorEntity):
|
||||
_element: Zone
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
# Zone in NORMAL state is OFF; any other state is ON
|
||||
self._attr_is_on = bool(
|
||||
self._element.logical_status != ZoneLogicalStatus.NORMAL
|
||||
|
||||
@@ -120,7 +120,7 @@ class ElkCounter(ElkSensor):
|
||||
_attr_icon = "mdi:numeric"
|
||||
_element: Counter
|
||||
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
self._attr_native_value = self._element.value
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ class ElkKeypad(ElkSensor):
|
||||
attrs["last_keypress"] = self._element.last_keypress
|
||||
return attrs
|
||||
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
self._attr_native_value = temperature_to_state(
|
||||
self._element.temperature, UNDEFINED_TEMPERATURE
|
||||
)
|
||||
@@ -173,7 +173,7 @@ class ElkPanel(ElkSensor):
|
||||
attrs["system_trouble_status"] = self._element.system_trouble_status
|
||||
return attrs
|
||||
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
if self._elk.is_connected():
|
||||
self._attr_native_value = (
|
||||
"Paused" if self._element.remote_programming_status else "Connected"
|
||||
@@ -188,7 +188,7 @@ class ElkSetting(ElkSensor):
|
||||
_attr_translation_key = "setting"
|
||||
_element: Setting
|
||||
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
self._attr_native_value = self._element.value
|
||||
|
||||
@property
|
||||
@@ -257,7 +257,7 @@ class ElkZone(ElkSensor):
|
||||
return UnitOfElectricPotential.VOLT
|
||||
return None
|
||||
|
||||
def _element_changed(self, _: Element, changeset: Any) -> None:
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
if self._element.definition == ZoneType.TEMPERATURE:
|
||||
self._attr_native_value = temperature_to_state(
|
||||
self._element.temperature, UNDEFINED_TEMPERATURE
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["openwebif"],
|
||||
"requirements": ["openwebifpy==4.3.1"]
|
||||
"requirements": ["openwebifpy==4.3.0"]
|
||||
}
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.0.0"]
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from aioesphomeapi import APIClient, DeviceInfo
|
||||
from bleak_esphome import connect_scanner
|
||||
from bleak_esphome.backend.cache import ESPHomeBluetoothCache
|
||||
|
||||
from homeassistant.components.bluetooth import async_register_scanner
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
|
||||
@@ -27,9 +28,10 @@ def async_connect_scanner(
|
||||
entry_data: RuntimeEntryData,
|
||||
cli: APIClient,
|
||||
device_info: DeviceInfo,
|
||||
cache: ESPHomeBluetoothCache,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Connect scanner."""
|
||||
client_data = connect_scanner(cli, device_info, entry_data.available)
|
||||
client_data = connect_scanner(cli, device_info, cache, entry_data.available)
|
||||
entry_data.bluetooth_device = client_data.bluetooth_device
|
||||
client_data.disconnect_callbacks = entry_data.disconnect_callbacks
|
||||
scanner = client_data.scanner
|
||||
|
||||
@@ -6,6 +6,8 @@ from dataclasses import dataclass, field
|
||||
from functools import cache
|
||||
from typing import Self
|
||||
|
||||
from bleak_esphome.backend.cache import ESPHomeBluetoothCache
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
|
||||
@@ -20,6 +22,9 @@ class DomainData:
|
||||
"""Define a class that stores global esphome data in hass.data[DOMAIN]."""
|
||||
|
||||
_stores: dict[str, ESPHomeStorage] = field(default_factory=dict)
|
||||
bluetooth_cache: ESPHomeBluetoothCache = field(
|
||||
default_factory=ESPHomeBluetoothCache
|
||||
)
|
||||
|
||||
def get_entry_data(self, entry: ESPHomeConfigEntry) -> RuntimeEntryData:
|
||||
"""Return the runtime entry data associated with this config entry.
|
||||
|
||||
@@ -423,7 +423,9 @@ class ESPHomeManager:
|
||||
|
||||
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
||||
entry_data.disconnect_callbacks.add(
|
||||
async_connect_scanner(hass, entry_data, cli, device_info)
|
||||
async_connect_scanner(
|
||||
hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache
|
||||
)
|
||||
)
|
||||
|
||||
if device_info.voice_assistant_feature_flags_compat(api_version) and (
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==28.0.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.0.0"
|
||||
"bleak-esphome==1.1.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.system_info import is_official_image
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util.signal_type import SignalType
|
||||
from homeassistant.util.system_info import is_official_image
|
||||
|
||||
DOMAIN = "ffmpeg"
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
from datetime import datetime as dt
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from pyflick import FlickAPI
|
||||
from pyflick.authentication import SimpleFlickAuth
|
||||
from pyflick.authentication import AbstractFlickAuth
|
||||
from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -21,8 +20,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, CONF_TOKEN_EXPIRY
|
||||
from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator
|
||||
from .const import CONF_TOKEN_EXPIRY, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,85 +29,36 @@ CONF_ID_TOKEN = "id_token"
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Flick Electric from a config entry."""
|
||||
auth = HassFlickAuth(hass, entry)
|
||||
|
||||
coordinator = FlickElectricDataCoordinator(
|
||||
hass, FlickAPI(auth), entry.data[CONF_SUPPLY_NODE_REF]
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = FlickAPI(auth)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
config_entry.version,
|
||||
config_entry.minor_version,
|
||||
)
|
||||
|
||||
if config_entry.version > 2:
|
||||
return False
|
||||
|
||||
if config_entry.version == 1:
|
||||
api = FlickAPI(HassFlickAuth(hass, config_entry))
|
||||
|
||||
accounts = await api.getCustomerAccounts()
|
||||
active_accounts = [
|
||||
account for account in accounts if account["status"] == "active"
|
||||
]
|
||||
|
||||
# A single active account can be auto-migrated
|
||||
if (len(active_accounts)) == 1:
|
||||
account = active_accounts[0]
|
||||
|
||||
new_data = {**config_entry.data}
|
||||
new_data[CONF_ACCOUNT_ID] = account["id"]
|
||||
new_data[CONF_SUPPLY_NODE_REF] = account["main_consumer"]["supply_node_ref"]
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
title=account["address"],
|
||||
unique_id=account["id"],
|
||||
data=new_data,
|
||||
version=2,
|
||||
)
|
||||
return True
|
||||
|
||||
config_entry.async_start_reauth(hass, data={**config_entry.data})
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HassFlickAuth(SimpleFlickAuth):
|
||||
class HassFlickAuth(AbstractFlickAuth):
|
||||
"""Implementation of AbstractFlickAuth based on a Home Assistant entity config."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: FlickConfigEntry) -> None:
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Flick authentication based on a Home Assistant entity config."""
|
||||
super().__init__(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
client_id=entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID),
|
||||
client_secret=entry.data.get(CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET),
|
||||
websession=aiohttp_client.async_get_clientsession(hass),
|
||||
)
|
||||
super().__init__(aiohttp_client.async_get_clientsession(hass))
|
||||
self._entry = entry
|
||||
self._hass = hass
|
||||
|
||||
async def _get_entry_token(self) -> dict[str, Any]:
|
||||
async def _get_entry_token(self):
|
||||
# No token saved, generate one
|
||||
if (
|
||||
CONF_TOKEN_EXPIRY not in self._entry.data
|
||||
@@ -126,8 +75,13 @@ class HassFlickAuth(SimpleFlickAuth):
|
||||
async def _update_token(self):
|
||||
_LOGGER.debug("Fetching new access token")
|
||||
|
||||
token = await super().get_new_token(
|
||||
self._username, self._password, self._client_id, self._client_secret
|
||||
token = await self.get_new_token(
|
||||
username=self._entry.data[CONF_USERNAME],
|
||||
password=self._entry.data[CONF_PASSWORD],
|
||||
client_id=self._entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID),
|
||||
client_secret=self._entry.data.get(
|
||||
CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET
|
||||
),
|
||||
)
|
||||
|
||||
_LOGGER.debug("New token: %s", token)
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
"""Config Flow for Flick Electric integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from pyflick import FlickAPI
|
||||
from pyflick.authentication import AbstractFlickAuth, SimpleFlickAuth
|
||||
from pyflick.authentication import AuthException, SimpleFlickAuth
|
||||
from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET
|
||||
from pyflick.types import APIException, AuthException, CustomerAccount
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
@@ -21,18 +17,12 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_ACCOUNT_ID, CONF_SUPPLY_NODE_REF, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LOGIN_SCHEMA = vol.Schema(
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
@@ -45,13 +35,10 @@ LOGIN_SCHEMA = vol.Schema(
|
||||
class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Flick config flow."""
|
||||
|
||||
VERSION = 2
|
||||
auth: AbstractFlickAuth
|
||||
accounts: list[CustomerAccount]
|
||||
data: dict[str, Any]
|
||||
VERSION = 1
|
||||
|
||||
async def _validate_auth(self, user_input: Mapping[str, Any]) -> bool:
|
||||
self.auth = SimpleFlickAuth(
|
||||
async def _validate_input(self, user_input):
|
||||
auth = SimpleFlickAuth(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
websession=aiohttp_client.async_get_clientsession(self.hass),
|
||||
@@ -61,83 +48,22 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(60):
|
||||
token = await self.auth.async_get_access_token()
|
||||
except (TimeoutError, ClientResponseError) as err:
|
||||
token = await auth.async_get_access_token()
|
||||
except TimeoutError as err:
|
||||
raise CannotConnect from err
|
||||
except AuthException as err:
|
||||
raise InvalidAuth from err
|
||||
|
||||
return token is not None
|
||||
|
||||
async def async_step_select_account(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Ask user to select account."""
|
||||
|
||||
errors = {}
|
||||
if user_input is not None and CONF_ACCOUNT_ID in user_input:
|
||||
self.data[CONF_ACCOUNT_ID] = user_input[CONF_ACCOUNT_ID]
|
||||
self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref(
|
||||
user_input[CONF_ACCOUNT_ID]
|
||||
)
|
||||
try:
|
||||
# Ensure supply node is active
|
||||
await FlickAPI(self.auth).getPricing(self.data[CONF_SUPPLY_NODE_REF])
|
||||
except (APIException, ClientResponseError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except AuthException:
|
||||
# We should never get here as we have a valid token
|
||||
return self.async_abort(reason="no_permissions")
|
||||
else:
|
||||
# Supply node is active
|
||||
return await self._async_create_entry()
|
||||
|
||||
try:
|
||||
self.accounts = await FlickAPI(self.auth).getCustomerAccounts()
|
||||
except (APIException, ClientResponseError):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
active_accounts = [a for a in self.accounts if a["status"] == "active"]
|
||||
|
||||
if len(active_accounts) == 0:
|
||||
return self.async_abort(reason="no_accounts")
|
||||
|
||||
if len(active_accounts) == 1:
|
||||
self.data[CONF_ACCOUNT_ID] = active_accounts[0]["id"]
|
||||
self.data[CONF_SUPPLY_NODE_REF] = self._get_supply_node_ref(
|
||||
active_accounts[0]["id"]
|
||||
)
|
||||
|
||||
return await self._async_create_entry()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="select_account",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(
|
||||
value=account["id"], label=account["address"]
|
||||
)
|
||||
for account in active_accounts
|
||||
],
|
||||
mode=SelectSelectorMode.LIST,
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle gathering login info."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
await self._validate_auth(user_input)
|
||||
await self._validate_input(user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
@@ -146,61 +72,20 @@ class FlickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self.data = dict(user_input)
|
||||
return await self.async_step_select_account(user_input)
|
||||
await self.async_set_unique_id(
|
||||
f"flick_electric_{user_input[CONF_USERNAME]}"
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=LOGIN_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication."""
|
||||
|
||||
self.data = {**user_input}
|
||||
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def _async_create_entry(self) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow."""
|
||||
|
||||
await self.async_set_unique_id(self.data[CONF_ACCOUNT_ID])
|
||||
|
||||
account = self._get_account(self.data[CONF_ACCOUNT_ID])
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
# Migration completed
|
||||
if self._get_reauth_entry().version == 1:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._get_reauth_entry(),
|
||||
unique_id=self.unique_id,
|
||||
data=self.data,
|
||||
version=self.VERSION,
|
||||
return self.async_create_entry(
|
||||
title=f"Flick Electric: {user_input[CONF_USERNAME]}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
unique_id=self.unique_id,
|
||||
title=account["address"],
|
||||
data=self.data,
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=account["address"],
|
||||
data=self.data,
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
def _get_account(self, account_id: str) -> CustomerAccount:
|
||||
"""Get the account for the account ID."""
|
||||
return next(a for a in self.accounts if a["id"] == account_id)
|
||||
|
||||
def _get_supply_node_ref(self, account_id: str) -> str:
|
||||
"""Get the supply node ref for the account."""
|
||||
return self._get_account(account_id)["main_consumer"][CONF_SUPPLY_NODE_REF]
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
DOMAIN = "flick_electric"
|
||||
|
||||
CONF_TOKEN_EXPIRY = "expires"
|
||||
CONF_ACCOUNT_ID = "account_id"
|
||||
CONF_SUPPLY_NODE_REF = "supply_node_ref"
|
||||
|
||||
ATTR_START_AT = "start_at"
|
||||
ATTR_END_AT = "end_at"
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Data Coordinator for Flick Electric."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from pyflick import FlickAPI, FlickPrice
|
||||
from pyflick.types import APIException, AuthException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
type FlickConfigEntry = ConfigEntry[FlickElectricDataCoordinator]
|
||||
|
||||
|
||||
class FlickElectricDataCoordinator(DataUpdateCoordinator[FlickPrice]):
|
||||
"""Coordinator for flick power price."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, api: FlickAPI, supply_node_ref: str
|
||||
) -> None:
|
||||
"""Initialize FlickElectricDataCoordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Flick Electric",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.supply_node_ref = supply_node_ref
|
||||
self._api = api
|
||||
|
||||
async def _async_update_data(self) -> FlickPrice:
|
||||
"""Fetch pricing data from Flick Electric."""
|
||||
try:
|
||||
async with asyncio.timeout(60):
|
||||
return await self._api.getPricing(self.supply_node_ref)
|
||||
except AuthException as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (APIException, aiohttp.ClientResponseError) as err:
|
||||
raise UpdateFailed from err
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyflick"],
|
||||
"requirements": ["PyFlick==1.1.3"]
|
||||
"requirements": ["PyFlick==0.0.2"]
|
||||
}
|
||||
|
||||
@@ -1,72 +1,74 @@
|
||||
"""Support for Flick Electric Pricing data."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyflick import FlickAPI, FlickPrice
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CURRENCY_CENT, UnitOfEnergy
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT
|
||||
from .coordinator import FlickConfigEntry, FlickElectricDataCoordinator
|
||||
from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FlickConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Flick Sensor Setup."""
|
||||
coordinator = entry.runtime_data
|
||||
api: FlickAPI = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities([FlickPricingSensor(coordinator)])
|
||||
async_add_entities([FlickPricingSensor(api)], True)
|
||||
|
||||
|
||||
class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], SensorEntity):
|
||||
class FlickPricingSensor(SensorEntity):
|
||||
"""Entity object for Flick Electric sensor."""
|
||||
|
||||
_attr_attribution = "Data provided by Flick Electric"
|
||||
_attr_native_unit_of_measurement = f"{CURRENCY_CENT}/{UnitOfEnergy.KILO_WATT_HOUR}"
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "power_price"
|
||||
_attributes: dict[str, Any] = {}
|
||||
|
||||
def __init__(self, coordinator: FlickElectricDataCoordinator) -> None:
|
||||
def __init__(self, api: FlickAPI) -> None:
|
||||
"""Entity object for Flick Electric sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.supply_node_ref}_pricing"
|
||||
self._api: FlickAPI = api
|
||||
self._price: FlickPrice = None
|
||||
|
||||
@property
|
||||
def native_value(self) -> Decimal:
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
# The API should return a unit price with quantity of 1.0 when no start/end time is provided
|
||||
if self.coordinator.data.quantity != 1:
|
||||
_LOGGER.warning(
|
||||
"Unexpected quantity for unit price: %s", self.coordinator.data
|
||||
)
|
||||
return self.coordinator.data.cost * 100
|
||||
return self._price.price
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
components: dict[str, float] = {}
|
||||
return self._attributes
|
||||
|
||||
for component in self.coordinator.data.components:
|
||||
async def async_update(self) -> None:
|
||||
"""Get the Flick Pricing data from the web service."""
|
||||
if self._price and self._price.end_at >= utcnow():
|
||||
return # Power price data is still valid
|
||||
|
||||
async with asyncio.timeout(60):
|
||||
self._price = await self._api.getPricing()
|
||||
|
||||
_LOGGER.debug("Pricing data: %s", self._price)
|
||||
|
||||
self._attributes[ATTR_START_AT] = self._price.start_at
|
||||
self._attributes[ATTR_END_AT] = self._price.end_at
|
||||
for component in self._price.components:
|
||||
if component.charge_setter not in ATTR_COMPONENTS:
|
||||
_LOGGER.warning("Found unknown component: %s", component.charge_setter)
|
||||
continue
|
||||
|
||||
components[component.charge_setter] = float(component.value * 100)
|
||||
|
||||
return {
|
||||
ATTR_START_AT: self.coordinator.data.start_at,
|
||||
ATTR_END_AT: self.coordinator.data.end_at,
|
||||
**components,
|
||||
}
|
||||
self._attributes[component.charge_setter] = float(component.value)
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
"client_id": "Client ID (optional)",
|
||||
"client_secret": "Client Secret (optional)"
|
||||
}
|
||||
},
|
||||
"select_account": {
|
||||
"title": "Select account",
|
||||
"data": {
|
||||
"account_id": "Account"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -23,10 +17,7 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"no_permissions": "Cannot get pricing for this account. Please check user permissions.",
|
||||
"no_accounts": "No services are active on this Flick account"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -214,18 +214,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self._options = options
|
||||
await self.hass.async_add_executor_job(self.setup)
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
configuration_url=f"http://{self.host}",
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.mac)},
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
manufacturer="AVM",
|
||||
model=self.model,
|
||||
name=self.config_entry.title,
|
||||
sw_version=self.current_firmware,
|
||||
)
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up FritzboxTools class."""
|
||||
|
||||
|
||||
@@ -68,14 +68,23 @@ class FritzBoxBaseEntity:
|
||||
"""Init device info class."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._device_name = device_name
|
||||
self.mac_address = self._avm_wrapper.mac
|
||||
|
||||
@property
|
||||
def mac_address(self) -> str:
|
||||
"""Return the mac address of the main device."""
|
||||
return self._avm_wrapper.mac
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device information."""
|
||||
return DeviceInfo(
|
||||
configuration_url=f"http://{self._avm_wrapper.host}",
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)},
|
||||
identifiers={(DOMAIN, self._avm_wrapper.unique_id)},
|
||||
manufacturer="AVM",
|
||||
model=self._avm_wrapper.model,
|
||||
name=self._device_name,
|
||||
sw_version=self._avm_wrapper.current_firmware,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"after_dependencies": ["backup"],
|
||||
"codeowners": ["@home-assistant/frontend"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
@@ -21,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250109.0"]
|
||||
"requirements": ["home-assistant-frontend==20241230.0"]
|
||||
}
|
||||
|
||||
@@ -255,10 +255,6 @@ async def async_test_and_preview_stream(
|
||||
"""
|
||||
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
|
||||
return None
|
||||
# Import from stream.worker as stream cannot reexport from worker
|
||||
# without forcing the av dependency on default_config
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.stream.worker import StreamWorkerError
|
||||
|
||||
if not isinstance(stream_source, template_helper.Template):
|
||||
stream_source = template_helper.Template(stream_source, hass)
|
||||
@@ -294,8 +290,6 @@ async def async_test_and_preview_stream(
|
||||
f"{DOMAIN}.test_stream",
|
||||
)
|
||||
hls_provider = stream.add_provider(HLS_PROVIDER)
|
||||
except StreamWorkerError as err:
|
||||
raise InvalidStreamException("unknown_with_details", str(err)) from err
|
||||
except PermissionError as err:
|
||||
raise InvalidStreamException("stream_not_permitted") from err
|
||||
except OSError as err:
|
||||
@@ -349,6 +343,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the start of the config flow."""
|
||||
errors = {}
|
||||
description_placeholders = {}
|
||||
hass = self.hass
|
||||
if user_input:
|
||||
# Secondary validation because serialised vol can't seem to handle this complexity:
|
||||
@@ -364,6 +359,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
except InvalidStreamException as err:
|
||||
errors[CONF_STREAM_SOURCE] = str(err)
|
||||
if err.details:
|
||||
errors["error_details"] = err.details
|
||||
self.preview_stream = None
|
||||
if not errors:
|
||||
user_input[CONF_CONTENT_TYPE] = still_format
|
||||
@@ -382,6 +379,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# temporary preview for user to check the image
|
||||
self.preview_cam = user_input
|
||||
return await self.async_step_user_confirm()
|
||||
if "error_details" in errors:
|
||||
description_placeholders["error"] = errors.pop("error_details")
|
||||
elif self.user_input:
|
||||
user_input = self.user_input
|
||||
else:
|
||||
@@ -389,6 +388,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=build_schema(user_input),
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -406,6 +406,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=self.title, data={}, options=self.user_input
|
||||
)
|
||||
register_preview(self.hass)
|
||||
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
|
||||
return self.async_show_form(
|
||||
step_id="user_confirm",
|
||||
data_schema=vol.Schema(
|
||||
@@ -413,6 +414,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={"preview_url": preview_url},
|
||||
errors=None,
|
||||
preview="generic_camera",
|
||||
)
|
||||
@@ -429,7 +431,6 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Generic IP Camera options flow."""
|
||||
self.preview_cam: dict[str, Any] = {}
|
||||
self.preview_stream: Stream | None = None
|
||||
self.user_input: dict[str, Any] = {}
|
||||
|
||||
async def async_step_init(
|
||||
@@ -437,45 +438,42 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage Generic IP Camera options."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders = {}
|
||||
hass = self.hass
|
||||
|
||||
if user_input:
|
||||
# Secondary validation because serialised vol can't seem to handle this complexity:
|
||||
if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get(
|
||||
CONF_STREAM_SOURCE
|
||||
):
|
||||
errors["base"] = "no_still_image_or_stream_url"
|
||||
else:
|
||||
errors, still_format = await async_test_still(hass, user_input)
|
||||
try:
|
||||
self.preview_stream = await async_test_and_preview_stream(
|
||||
hass, user_input
|
||||
)
|
||||
except InvalidStreamException as err:
|
||||
errors[CONF_STREAM_SOURCE] = str(err)
|
||||
self.preview_stream = None
|
||||
if not errors:
|
||||
user_input[CONF_CONTENT_TYPE] = still_format
|
||||
still_url = user_input.get(CONF_STILL_IMAGE_URL)
|
||||
if still_url is None:
|
||||
# If user didn't specify a still image URL,
|
||||
# The automatically generated still image that stream generates
|
||||
# is always jpeg
|
||||
still_format = "image/jpeg"
|
||||
data = {
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
|
||||
),
|
||||
**user_input,
|
||||
CONF_CONTENT_TYPE: still_format
|
||||
or self.config_entry.options.get(CONF_CONTENT_TYPE),
|
||||
}
|
||||
self.user_input = data
|
||||
# temporary preview for user to check the image
|
||||
self.preview_cam = data
|
||||
return await self.async_step_user_confirm()
|
||||
elif self.user_input:
|
||||
user_input = self.user_input
|
||||
if user_input is not None:
|
||||
errors, still_format = await async_test_still(
|
||||
hass, self.config_entry.options | user_input
|
||||
)
|
||||
try:
|
||||
await async_test_and_preview_stream(hass, user_input)
|
||||
except InvalidStreamException as err:
|
||||
errors[CONF_STREAM_SOURCE] = str(err)
|
||||
if err.details:
|
||||
errors["error_details"] = err.details
|
||||
# Stream preview during options flow not yet implemented
|
||||
|
||||
still_url = user_input.get(CONF_STILL_IMAGE_URL)
|
||||
if not errors:
|
||||
if still_url is None:
|
||||
# If user didn't specify a still image URL,
|
||||
# The automatically generated still image that stream generates
|
||||
# is always jpeg
|
||||
still_format = "image/jpeg"
|
||||
data = {
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
|
||||
),
|
||||
**user_input,
|
||||
CONF_CONTENT_TYPE: still_format
|
||||
or self.config_entry.options.get(CONF_CONTENT_TYPE),
|
||||
}
|
||||
self.user_input = data
|
||||
# temporary preview for user to check the image
|
||||
self.preview_cam = data
|
||||
return await self.async_step_confirm_still()
|
||||
if "error_details" in errors:
|
||||
description_placeholders["error"] = errors.pop("error_details")
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=build_schema(
|
||||
@@ -483,17 +481,15 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
True,
|
||||
self.show_advanced_options,
|
||||
),
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_user_confirm(
|
||||
async def async_step_confirm_still(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user clicking confirm after still preview."""
|
||||
if user_input:
|
||||
if ha_stream := self.preview_stream:
|
||||
# Kill off the temp stream we created.
|
||||
await ha_stream.stop()
|
||||
if not user_input.get(CONF_CONFIRMED_OK):
|
||||
return await self.async_step_init()
|
||||
return self.async_create_entry(
|
||||
@@ -501,22 +497,18 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
data=self.user_input,
|
||||
)
|
||||
register_preview(self.hass)
|
||||
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
|
||||
return self.async_show_form(
|
||||
step_id="user_confirm",
|
||||
step_id="confirm_still",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders={"preview_url": preview_url},
|
||||
errors=None,
|
||||
preview="generic_camera",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def async_setup_preview(hass: HomeAssistant) -> None:
|
||||
"""Set up preview WS API."""
|
||||
websocket_api.async_register_command(hass, ws_start_preview)
|
||||
|
||||
|
||||
class CameraImagePreview(HomeAssistantView):
|
||||
"""Camera view to temporarily serve an image."""
|
||||
@@ -558,7 +550,7 @@ class CameraImagePreview(HomeAssistantView):
|
||||
{
|
||||
vol.Required("type"): "generic_camera/start_preview",
|
||||
vol.Required("flow_id"): str,
|
||||
vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"),
|
||||
vol.Optional("flow_type"): vol.Any("config_flow"),
|
||||
vol.Optional("user_input"): dict,
|
||||
}
|
||||
)
|
||||
@@ -572,17 +564,10 @@ async def ws_start_preview(
|
||||
_LOGGER.debug("Generating websocket handler for generic camera preview")
|
||||
|
||||
flow_id = msg["flow_id"]
|
||||
flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler
|
||||
if msg.get("flow_type", "config_flow") == "config_flow":
|
||||
flow = cast(
|
||||
GenericIPCamConfigFlow,
|
||||
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
|
||||
)
|
||||
else: # (flow type == "options flow")
|
||||
flow = cast(
|
||||
GenericOptionsFlowHandler,
|
||||
hass.config_entries.options._progress.get(flow_id), # noqa: SLF001
|
||||
)
|
||||
flow = cast(
|
||||
GenericIPCamConfigFlow,
|
||||
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
|
||||
)
|
||||
user_input = flow.preview_cam
|
||||
|
||||
# Create an EntityPlatform, needed for name translations
|
||||
|
||||
@@ -67,11 +67,11 @@
|
||||
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
|
||||
}
|
||||
},
|
||||
"user_confirm": {
|
||||
"title": "Confirmation",
|
||||
"description": "Please wait for previews to load...",
|
||||
"confirm_still": {
|
||||
"title": "Preview",
|
||||
"description": "",
|
||||
"data": {
|
||||
"confirmed_ok": "Everything looks good."
|
||||
"confirmed_ok": "This image looks good."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,18 +34,6 @@
|
||||
"moderate": "Moderate",
|
||||
"good": "Good",
|
||||
"very_good": "Very good"
|
||||
},
|
||||
"state_attributes": {
|
||||
"options": {
|
||||
"state": {
|
||||
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
|
||||
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
|
||||
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"c6h6": {
|
||||
@@ -63,18 +51,6 @@
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
},
|
||||
"state_attributes": {
|
||||
"options": {
|
||||
"state": {
|
||||
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
|
||||
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
|
||||
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"o3_index": {
|
||||
@@ -86,18 +62,6 @@
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
},
|
||||
"state_attributes": {
|
||||
"options": {
|
||||
"state": {
|
||||
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
|
||||
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
|
||||
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pm10_index": {
|
||||
@@ -109,18 +73,6 @@
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
},
|
||||
"state_attributes": {
|
||||
"options": {
|
||||
"state": {
|
||||
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
|
||||
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
|
||||
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pm25_index": {
|
||||
@@ -132,18 +84,6 @@
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
},
|
||||
"state_attributes": {
|
||||
"options": {
|
||||
"state": {
|
||||
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
|
||||
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
|
||||
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"so2_index": {
|
||||
@@ -155,18 +95,6 @@
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
},
|
||||
"state_attributes": {
|
||||
"options": {
|
||||
"state": {
|
||||
"very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]",
|
||||
"bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]",
|
||||
"sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]",
|
||||
"moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]",
|
||||
"good": "[%key:component::gios::entity::sensor::aqi::state::good%]",
|
||||
"very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,9 @@ class GoogleGenerativeAIConversationEntity(
|
||||
"""Process a sentence."""
|
||||
result = conversation.ConversationResult(
|
||||
response=intent.IntentResponse(language=user_input.language),
|
||||
conversation_id=user_input.conversation_id or ulid.ulid_now(),
|
||||
conversation_id=user_input.conversation_id
|
||||
if user_input.conversation_id in self.history
|
||||
else ulid.ulid_now(),
|
||||
)
|
||||
assert result.conversation_id
|
||||
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
"""The habitica integration."""
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from habitipy.aio import HabitipyAsync
|
||||
from habiticalib import Habitica
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
APPLICATION_NAME,
|
||||
CONF_API_KEY,
|
||||
CONF_NAME,
|
||||
CONF_URL,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_API_USER, DEVELOPER_ID, DOMAIN
|
||||
from .const import CONF_API_USER, DOMAIN, X_CLIENT
|
||||
from .coordinator import HabiticaDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
from .types import HabiticaConfigEntry
|
||||
@@ -51,47 +39,17 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up habitica from a config entry."""
|
||||
|
||||
class HAHabitipyAsync(HabitipyAsync):
|
||||
"""Closure API class to hold session."""
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
return super().__call__(websession, **kwargs)
|
||||
|
||||
def _make_headers(self) -> dict[str, str]:
|
||||
headers = super()._make_headers()
|
||||
headers.update(
|
||||
{"x-client": f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"}
|
||||
)
|
||||
return headers
|
||||
|
||||
websession = async_get_clientsession(
|
||||
session = async_get_clientsession(
|
||||
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
|
||||
api = await hass.async_add_executor_job(
|
||||
HAHabitipyAsync,
|
||||
{
|
||||
"url": config_entry.data[CONF_URL],
|
||||
"login": config_entry.data[CONF_API_USER],
|
||||
"password": config_entry.data[CONF_API_KEY],
|
||||
},
|
||||
api = Habitica(
|
||||
session,
|
||||
api_user=config_entry.data[CONF_API_USER],
|
||||
api_key=config_entry.data[CONF_API_KEY],
|
||||
url=config_entry.data[CONF_URL],
|
||||
x_client=X_CLIENT,
|
||||
)
|
||||
try:
|
||||
user = await api.user.get(userFields="profile")
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
raise ConfigEntryNotReady(e) from e
|
||||
|
||||
if not config_entry.data.get(CONF_NAME):
|
||||
name = user["profile"]["name"]
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={**config_entry.data, CONF_NAME: name},
|
||||
)
|
||||
|
||||
coordinator = HabiticaDataUpdateCoordinator(hass, api)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from habiticalib import UserData
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
@@ -23,8 +24,8 @@ from .types import HabiticaConfigEntry
|
||||
class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Habitica Binary Sensor Description."""
|
||||
|
||||
value_fn: Callable[[dict[str, Any]], bool | None]
|
||||
entity_picture: Callable[[dict[str, Any]], str | None]
|
||||
value_fn: Callable[[UserData], bool | None]
|
||||
entity_picture: Callable[[UserData], str | None]
|
||||
|
||||
|
||||
class HabiticaBinarySensor(StrEnum):
|
||||
@@ -33,10 +34,10 @@ class HabiticaBinarySensor(StrEnum):
|
||||
PENDING_QUEST = "pending_quest"
|
||||
|
||||
|
||||
def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None:
|
||||
def get_scroll_image_for_pending_quest_invitation(user: UserData) -> str | None:
|
||||
"""Entity picture for pending quest invitation."""
|
||||
if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]:
|
||||
return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png"
|
||||
if user.party.quest.key and user.party.quest.RSVPNeeded:
|
||||
return f"inventory_quest_scroll_{user.party.quest.key}.png"
|
||||
return None
|
||||
|
||||
|
||||
@@ -44,7 +45,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] =
|
||||
HabiticaBinarySensorEntityDescription(
|
||||
key=HabiticaBinarySensor.PENDING_QUEST,
|
||||
translation_key=HabiticaBinarySensor.PENDING_QUEST,
|
||||
value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"],
|
||||
value_fn=lambda user: user.party.quest.RSVPNeeded,
|
||||
entity_picture=get_scroll_image_for_pending_quest_invitation,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,10 +5,17 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
HabiticaClass,
|
||||
HabiticaException,
|
||||
NotAuthorizedError,
|
||||
Skill,
|
||||
TaskType,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
|
||||
from homeassistant.components.button import (
|
||||
DOMAIN as BUTTON_DOMAIN,
|
||||
@@ -20,7 +27,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import ASSETS_URL, DOMAIN, HEALER, MAGE, ROGUE, WARRIOR
|
||||
from .const import ASSETS_URL, DOMAIN
|
||||
from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
from .types import HabiticaConfigEntry
|
||||
@@ -34,7 +41,7 @@ class HabiticaButtonEntityDescription(ButtonEntityDescription):
|
||||
|
||||
press_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
available_fn: Callable[[HabiticaData], bool]
|
||||
class_needed: str | None = None
|
||||
class_needed: HabiticaClass | None = None
|
||||
entity_picture: str | None = None
|
||||
|
||||
|
||||
@@ -63,35 +70,33 @@ BUTTON_DESCRIPTIONS: tuple[HabiticaButtonEntityDescription, ...] = (
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.RUN_CRON,
|
||||
translation_key=HabitipyButtonEntity.RUN_CRON,
|
||||
press_fn=lambda coordinator: coordinator.api.cron.post(),
|
||||
available_fn=lambda data: data.user["needsCron"],
|
||||
press_fn=lambda coordinator: coordinator.habitica.run_cron(),
|
||||
available_fn=lambda data: data.user.needsCron is True,
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.BUY_HEALTH_POTION,
|
||||
translation_key=HabitipyButtonEntity.BUY_HEALTH_POTION,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api["user"]["buy-health-potion"].post()
|
||||
),
|
||||
press_fn=lambda coordinator: coordinator.habitica.buy_health_potion(),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["gp"] >= 25
|
||||
and data.user["stats"]["hp"] < 50
|
||||
lambda data: (data.user.stats.gp or 0) >= 25
|
||||
and (data.user.stats.hp or 0) < 50
|
||||
),
|
||||
entity_picture="shop_potion.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||
translation_key=HabitipyButtonEntity.ALLOCATE_ALL_STAT_POINTS,
|
||||
press_fn=lambda coordinator: coordinator.api["user"]["allocate-now"].post(),
|
||||
press_fn=lambda coordinator: coordinator.habitica.allocate_stat_points(),
|
||||
available_fn=(
|
||||
lambda data: data.user["preferences"].get("automaticAllocation") is True
|
||||
and data.user["stats"]["points"] > 0
|
||||
lambda data: data.user.preferences.automaticAllocation is True
|
||||
and (data.user.stats.points or 0) > 0
|
||||
),
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.REVIVE,
|
||||
translation_key=HabitipyButtonEntity.REVIVE,
|
||||
press_fn=lambda coordinator: coordinator.api["user"]["revive"].post(),
|
||||
available_fn=lambda data: data.user["stats"]["hp"] == 0,
|
||||
press_fn=lambda coordinator: coordinator.habitica.revive(),
|
||||
available_fn=lambda data: data.user.stats.hp == 0,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -100,166 +105,170 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.MPHEAL,
|
||||
translation_key=HabitipyButtonEntity.MPHEAL,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["mpheal"].post(),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 12
|
||||
and data.user["stats"]["mp"] >= 30
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.ETHEREAL_SURGE)
|
||||
),
|
||||
class_needed=MAGE,
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||
and (data.user.stats.mp or 0) >= 30
|
||||
),
|
||||
class_needed=HabiticaClass.MAGE,
|
||||
entity_picture="shop_mpheal.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.EARTH,
|
||||
translation_key=HabitipyButtonEntity.EARTH,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["earth"].post(),
|
||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.EARTHQUAKE),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 35
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 35
|
||||
),
|
||||
class_needed=MAGE,
|
||||
class_needed=HabiticaClass.MAGE,
|
||||
entity_picture="shop_earth.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.FROST,
|
||||
translation_key=HabitipyButtonEntity.FROST,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["frost"].post(),
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.CHILLING_FROST)
|
||||
),
|
||||
# chilling frost can only be cast once per day (streaks buff is false)
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 40
|
||||
and not data.user["stats"]["buffs"]["streaks"]
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 40
|
||||
and not data.user.stats.buffs.streaks
|
||||
),
|
||||
class_needed=MAGE,
|
||||
class_needed=HabiticaClass.MAGE,
|
||||
entity_picture="shop_frost.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.DEFENSIVE_STANCE,
|
||||
translation_key=HabitipyButtonEntity.DEFENSIVE_STANCE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast[
|
||||
"defensiveStance"
|
||||
].post()
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.DEFENSIVE_STANCE)
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 12
|
||||
and data.user["stats"]["mp"] >= 25
|
||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||
and (data.user.stats.mp or 0) >= 25
|
||||
),
|
||||
class_needed=WARRIOR,
|
||||
class_needed=HabiticaClass.WARRIOR,
|
||||
entity_picture="shop_defensiveStance.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.VALOROUS_PRESENCE,
|
||||
translation_key=HabitipyButtonEntity.VALOROUS_PRESENCE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast[
|
||||
"valorousPresence"
|
||||
].post()
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.VALOROUS_PRESENCE)
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 20
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 20
|
||||
),
|
||||
class_needed=WARRIOR,
|
||||
class_needed=HabiticaClass.WARRIOR,
|
||||
entity_picture="shop_valorousPresence.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.INTIMIDATE,
|
||||
translation_key=HabitipyButtonEntity.INTIMIDATE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast["intimidate"].post()
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.INTIMIDATING_GAZE)
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 15
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 15
|
||||
),
|
||||
class_needed=WARRIOR,
|
||||
class_needed=HabiticaClass.WARRIOR,
|
||||
entity_picture="shop_intimidate.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.TOOLS_OF_TRADE,
|
||||
translation_key=HabitipyButtonEntity.TOOLS_OF_TRADE,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast["toolsOfTrade"].post()
|
||||
lambda coordinator: coordinator.habitica.cast_skill(
|
||||
Skill.TOOLS_OF_THE_TRADE
|
||||
)
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 25
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 25
|
||||
),
|
||||
class_needed=ROGUE,
|
||||
class_needed=HabiticaClass.ROGUE,
|
||||
entity_picture="shop_toolsOfTrade.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.STEALTH,
|
||||
translation_key=HabitipyButtonEntity.STEALTH,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast["stealth"].post()
|
||||
),
|
||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.STEALTH),
|
||||
# Stealth buffs stack and it can only be cast if the amount of
|
||||
# unfinished dailies is smaller than the amount of buffs
|
||||
# buffs is smaller than the amount of unfinished dailies
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 45
|
||||
and data.user["stats"]["buffs"]["stealth"]
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 45
|
||||
and (data.user.stats.buffs.stealth or 0)
|
||||
< len(
|
||||
[
|
||||
r
|
||||
for r in data.tasks
|
||||
if r.get("type") == "daily"
|
||||
and r.get("isDue") is True
|
||||
and r.get("completed") is False
|
||||
if r.Type is TaskType.DAILY
|
||||
and r.isDue is True
|
||||
and r.completed is False
|
||||
]
|
||||
)
|
||||
),
|
||||
class_needed=ROGUE,
|
||||
class_needed=HabiticaClass.ROGUE,
|
||||
entity_picture="shop_stealth.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.HEAL,
|
||||
translation_key=HabitipyButtonEntity.HEAL,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["heal"].post(),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 11
|
||||
and data.user["stats"]["mp"] >= 15
|
||||
and data.user["stats"]["hp"] < 50
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.HEALING_LIGHT)
|
||||
),
|
||||
class_needed=HEALER,
|
||||
available_fn=(
|
||||
lambda data: (data.user.stats.lvl or 0) >= 11
|
||||
and (data.user.stats.mp or 0) >= 15
|
||||
and (data.user.stats.hp or 0) < 50
|
||||
),
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
entity_picture="shop_heal.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.BRIGHTNESS,
|
||||
translation_key=HabitipyButtonEntity.BRIGHTNESS,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast["brightness"].post()
|
||||
lambda coordinator: coordinator.habitica.cast_skill(
|
||||
Skill.SEARING_BRIGHTNESS
|
||||
)
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 12
|
||||
and data.user["stats"]["mp"] >= 15
|
||||
lambda data: (data.user.stats.lvl or 0) >= 12
|
||||
and (data.user.stats.mp or 0) >= 15
|
||||
),
|
||||
class_needed=HEALER,
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
entity_picture="shop_brightness.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.PROTECT_AURA,
|
||||
translation_key=HabitipyButtonEntity.PROTECT_AURA,
|
||||
press_fn=(
|
||||
lambda coordinator: coordinator.api.user.class_.cast["protectAura"].post()
|
||||
lambda coordinator: coordinator.habitica.cast_skill(Skill.PROTECTIVE_AURA)
|
||||
),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 13
|
||||
and data.user["stats"]["mp"] >= 30
|
||||
lambda data: (data.user.stats.lvl or 0) >= 13
|
||||
and (data.user.stats.mp or 0) >= 30
|
||||
),
|
||||
class_needed=HEALER,
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
entity_picture="shop_protectAura.png",
|
||||
),
|
||||
HabiticaButtonEntityDescription(
|
||||
key=HabitipyButtonEntity.HEAL_ALL,
|
||||
translation_key=HabitipyButtonEntity.HEAL_ALL,
|
||||
press_fn=lambda coordinator: coordinator.api.user.class_.cast["healAll"].post(),
|
||||
press_fn=lambda coordinator: coordinator.habitica.cast_skill(Skill.BLESSING),
|
||||
available_fn=(
|
||||
lambda data: data.user["stats"]["lvl"] >= 14
|
||||
and data.user["stats"]["mp"] >= 25
|
||||
lambda data: (data.user.stats.lvl or 0) >= 14
|
||||
and (data.user.stats.mp or 0) >= 25
|
||||
),
|
||||
class_needed=HEALER,
|
||||
class_needed=HabiticaClass.HEALER,
|
||||
entity_picture="shop_healAll.png",
|
||||
),
|
||||
)
|
||||
@@ -285,10 +294,10 @@ async def async_setup_entry(
|
||||
|
||||
for description in CLASS_SKILLS:
|
||||
if (
|
||||
coordinator.data.user["stats"]["lvl"] >= 10
|
||||
and coordinator.data.user["flags"]["classSelected"]
|
||||
and not coordinator.data.user["preferences"]["disableClasses"]
|
||||
and description.class_needed == coordinator.data.user["stats"]["class"]
|
||||
(coordinator.data.user.stats.lvl or 0) >= 10
|
||||
and coordinator.data.user.flags.classSelected
|
||||
and not coordinator.data.user.preferences.disableClasses
|
||||
and description.class_needed is coordinator.data.user.stats.Class
|
||||
):
|
||||
if description.key not in skills_added:
|
||||
buttons.append(HabiticaButton(coordinator, description))
|
||||
@@ -322,17 +331,17 @@ class HabiticaButton(HabiticaBase, ButtonEntity):
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_unallowed",
|
||||
) from e
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_unallowed",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
|
||||
@@ -5,8 +5,11 @@ from __future__ import annotations
|
||||
from abc import abstractmethod
|
||||
from datetime import date, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from dateutil.rrule import rrule
|
||||
from habiticalib import TaskType
|
||||
|
||||
from homeassistant.components.calendar import (
|
||||
CalendarEntity,
|
||||
@@ -20,7 +23,6 @@ from homeassistant.util import dt as dt_util
|
||||
from . import HabiticaConfigEntry
|
||||
from .coordinator import HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
from .types import HabiticaTaskType
|
||||
from .util import build_rrule, get_recurrence_rule
|
||||
|
||||
|
||||
@@ -83,9 +85,7 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
|
||||
@property
|
||||
def start_of_today(self) -> datetime:
|
||||
"""Habitica daystart."""
|
||||
return dt_util.start_of_local_day(
|
||||
datetime.fromisoformat(self.coordinator.data.user["lastCron"])
|
||||
)
|
||||
return dt_util.start_of_local_day(self.coordinator.data.user.lastCron)
|
||||
|
||||
def get_recurrence_dates(
|
||||
self, recurrences: rrule, start_date: datetime, end_date: datetime | None = None
|
||||
@@ -115,13 +115,13 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
|
||||
events = []
|
||||
for task in self.coordinator.data.tasks:
|
||||
if not (
|
||||
task["type"] == HabiticaTaskType.TODO
|
||||
and not task["completed"]
|
||||
and task.get("date") # only if has due date
|
||||
task.Type is TaskType.TODO
|
||||
and not task.completed
|
||||
and task.date is not None # only if has due date
|
||||
):
|
||||
continue
|
||||
|
||||
start = dt_util.start_of_local_day(datetime.fromisoformat(task["date"]))
|
||||
start = dt_util.start_of_local_day(task.date)
|
||||
end = start + timedelta(days=1)
|
||||
# return current and upcoming events or events within the requested range
|
||||
|
||||
@@ -132,21 +132,23 @@ class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
|
||||
if end_date and start > end_date:
|
||||
# Event starts after date range
|
||||
continue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task.text
|
||||
assert task.id
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=start.date(),
|
||||
end=end.date(),
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=task["id"],
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=str(task.id),
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
events,
|
||||
key=lambda event: (
|
||||
event.start,
|
||||
self.coordinator.data.user["tasksOrder"]["todos"].index(event.uid),
|
||||
self.coordinator.data.user.tasksOrder.todos.index(UUID(event.uid)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -189,7 +191,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
|
||||
events = []
|
||||
for task in self.coordinator.data.tasks:
|
||||
# only dailies that that are not 'grey dailies'
|
||||
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
|
||||
if not (task.Type is TaskType.DAILY and task.everyX):
|
||||
continue
|
||||
|
||||
recurrences = build_rrule(task)
|
||||
@@ -199,19 +201,21 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
|
||||
for recurrence in recurrence_dates:
|
||||
is_future_event = recurrence > self.start_of_today
|
||||
is_current_event = (
|
||||
recurrence <= self.start_of_today and not task["completed"]
|
||||
recurrence <= self.start_of_today and not task.completed
|
||||
)
|
||||
|
||||
if not is_future_event and not is_current_event:
|
||||
continue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task.text
|
||||
assert task.id
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=recurrence.date(),
|
||||
end=self.end_date(recurrence, end_date),
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=task["id"],
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=str(task.id),
|
||||
rrule=get_recurrence_rule(recurrences),
|
||||
)
|
||||
)
|
||||
@@ -219,7 +223,7 @@ class HabiticaDailiesCalendarEntity(HabiticaCalendarEntity):
|
||||
events,
|
||||
key=lambda event: (
|
||||
event.start,
|
||||
self.coordinator.data.user["tasksOrder"]["dailys"].index(event.uid),
|
||||
self.coordinator.data.user.tasksOrder.dailys.index(UUID(event.uid)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -254,14 +258,14 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
events = []
|
||||
|
||||
for task in self.coordinator.data.tasks:
|
||||
if task["type"] != HabiticaTaskType.TODO or task["completed"]:
|
||||
if task.Type is not TaskType.TODO or task.completed:
|
||||
continue
|
||||
|
||||
for reminder in task.get("reminders", []):
|
||||
for reminder in task.reminders:
|
||||
# reminders are returned by the API in local time but with wrong
|
||||
# timezone (UTC) and arbitrary added seconds/microseconds. When
|
||||
# creating reminders in Habitica only hours and minutes can be defined.
|
||||
start = datetime.fromisoformat(reminder["time"]).replace(
|
||||
start = reminder.time.replace(
|
||||
tzinfo=dt_util.DEFAULT_TIME_ZONE, second=0, microsecond=0
|
||||
)
|
||||
end = start + timedelta(hours=1)
|
||||
@@ -273,14 +277,16 @@ class HabiticaTodoRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
if end_date and start > end_date:
|
||||
# Event starts after date range
|
||||
continue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task.text
|
||||
assert task.id
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=start,
|
||||
end=end,
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=f"{task["id"]}_{reminder["id"]}",
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=f"{task.id}_{reminder.id}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -298,7 +304,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
translation_key=HabiticaCalendar.DAILY_REMINDERS,
|
||||
)
|
||||
|
||||
def start(self, reminder_time: str, reminder_date: date) -> datetime:
|
||||
def start(self, reminder_time: datetime, reminder_date: date) -> datetime:
|
||||
"""Generate reminder times for dailies.
|
||||
|
||||
Reminders for dailies have a datetime but the date part is arbitrary,
|
||||
@@ -307,12 +313,10 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
"""
|
||||
return datetime.combine(
|
||||
reminder_date,
|
||||
datetime.fromisoformat(reminder_time)
|
||||
.replace(
|
||||
reminder_time.replace(
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
.time(),
|
||||
).time(),
|
||||
tzinfo=dt_util.DEFAULT_TIME_ZONE,
|
||||
)
|
||||
|
||||
@@ -327,7 +331,7 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
start_date = max(start_date, self.start_of_today)
|
||||
|
||||
for task in self.coordinator.data.tasks:
|
||||
if not (task["type"] == HabiticaTaskType.DAILY and task["everyX"]):
|
||||
if not (task.Type is TaskType.DAILY and task.everyX):
|
||||
continue
|
||||
|
||||
recurrences = build_rrule(task)
|
||||
@@ -339,27 +343,30 @@ class HabiticaDailyRemindersCalendarEntity(HabiticaCalendarEntity):
|
||||
for recurrence in recurrence_dates:
|
||||
is_future_event = recurrence > self.start_of_today
|
||||
is_current_event = (
|
||||
recurrence <= self.start_of_today and not task["completed"]
|
||||
recurrence <= self.start_of_today and not task.completed
|
||||
)
|
||||
|
||||
if not is_future_event and not is_current_event:
|
||||
continue
|
||||
|
||||
for reminder in task.get("reminders", []):
|
||||
start = self.start(reminder["time"], recurrence)
|
||||
for reminder in task.reminders:
|
||||
start = self.start(reminder.time, recurrence)
|
||||
end = start + timedelta(hours=1)
|
||||
|
||||
if end < start_date:
|
||||
# Event ends before date range
|
||||
continue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task.id
|
||||
assert task.text
|
||||
events.append(
|
||||
CalendarEvent(
|
||||
start=start,
|
||||
end=end,
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
uid=f"{task["id"]}_{reminder["id"]}",
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
uid=f"{task.id}_{reminder.id}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from habitipy.aio import HabitipyAsync
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
Habitica,
|
||||
HabiticaException,
|
||||
LoginData,
|
||||
NotAuthorizedError,
|
||||
UserData,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
@@ -25,14 +33,18 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import HabiticaConfigEntry
|
||||
from .const import (
|
||||
CONF_API_USER,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
FORGOT_PASSWORD_URL,
|
||||
HABITICANS_URL,
|
||||
SECTION_REAUTH_API_KEY,
|
||||
SECTION_REAUTH_LOGIN,
|
||||
SIGN_UP_URL,
|
||||
SITE_DATA_URL,
|
||||
X_CLIENT,
|
||||
)
|
||||
|
||||
STEP_ADVANCED_DATA_SCHEMA = vol.Schema(
|
||||
@@ -61,14 +73,44 @@ STEP_LOGIN_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(SECTION_REAUTH_LOGIN): data_entry_flow.section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_USERNAME): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.EMAIL,
|
||||
autocomplete="email",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
{"collapsed": False},
|
||||
),
|
||||
vol.Required(SECTION_REAUTH_API_KEY): data_entry_flow.section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_API_KEY): str,
|
||||
},
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for habitica."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -93,39 +135,20 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
session = async_get_clientsession(self.hass)
|
||||
api = await self.hass.async_add_executor_job(
|
||||
HabitipyAsync,
|
||||
{
|
||||
"login": "",
|
||||
"password": "",
|
||||
"url": DEFAULT_URL,
|
||||
},
|
||||
)
|
||||
login_response = await api.user.auth.local.login.post(
|
||||
session=session,
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
except ClientResponseError as ex:
|
||||
if ex.status == HTTPStatus.UNAUTHORIZED:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(login_response["id"])
|
||||
errors, login, user = await self.validate_login(
|
||||
{**user_input, CONF_URL: DEFAULT_URL}
|
||||
)
|
||||
if not errors and login is not None and user is not None:
|
||||
await self.async_set_unique_id(str(login.id))
|
||||
self._abort_if_unique_id_configured()
|
||||
if TYPE_CHECKING:
|
||||
assert user.profile.name
|
||||
return self.async_create_entry(
|
||||
title=login_response["username"],
|
||||
title=user.profile.name,
|
||||
data={
|
||||
CONF_API_USER: login_response["id"],
|
||||
CONF_API_KEY: login_response["apiToken"],
|
||||
CONF_USERNAME: login_response["username"],
|
||||
CONF_API_USER: str(login.id),
|
||||
CONF_API_KEY: login.apiToken,
|
||||
CONF_NAME: user.profile.name, # needed for api_call action
|
||||
CONF_URL: DEFAULT_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
@@ -150,36 +173,19 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
session = async_get_clientsession(
|
||||
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
api = await self.hass.async_add_executor_job(
|
||||
HabitipyAsync,
|
||||
{
|
||||
"login": user_input[CONF_API_USER],
|
||||
"password": user_input[CONF_API_KEY],
|
||||
"url": user_input.get(CONF_URL, DEFAULT_URL),
|
||||
},
|
||||
)
|
||||
api_response = await api.user.get(
|
||||
session=session,
|
||||
userFields="auth",
|
||||
)
|
||||
except ClientResponseError as ex:
|
||||
if ex.status == HTTPStatus.UNAUTHORIZED:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_API_USER])
|
||||
self._abort_if_unique_id_configured()
|
||||
user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"]
|
||||
await self.async_set_unique_id(user_input[CONF_API_USER])
|
||||
self._abort_if_unique_id_configured()
|
||||
errors, user = await self.validate_api_key(user_input)
|
||||
if not errors and user is not None:
|
||||
if TYPE_CHECKING:
|
||||
assert user.profile.name
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME], data=user_input
|
||||
title=user.profile.name,
|
||||
data={
|
||||
**user_input,
|
||||
CONF_URL: user_input.get(CONF_URL, DEFAULT_URL),
|
||||
CONF_NAME: user.profile.name, # needed for api_call action
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -193,3 +199,120 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"default_url": DEFAULT_URL,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry: HabiticaConfigEntry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME) and user_input[
|
||||
SECTION_REAUTH_LOGIN
|
||||
].get(CONF_PASSWORD):
|
||||
errors, login, _ = await self.validate_login(
|
||||
{**reauth_entry.data, **user_input[SECTION_REAUTH_LOGIN]}
|
||||
)
|
||||
if not errors and login is not None:
|
||||
await self.async_set_unique_id(str(login.id))
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_API_KEY: login.apiToken},
|
||||
)
|
||||
elif user_input[SECTION_REAUTH_API_KEY].get(CONF_API_KEY):
|
||||
errors, user = await self.validate_api_key(
|
||||
{
|
||||
**reauth_entry.data,
|
||||
**user_input[SECTION_REAUTH_API_KEY],
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
|
||||
)
|
||||
else:
|
||||
errors["base"] = "invalid_credentials"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
suggested_values={
|
||||
CONF_USERNAME: (
|
||||
user_input[SECTION_REAUTH_LOGIN].get(CONF_USERNAME)
|
||||
if user_input
|
||||
else None,
|
||||
)
|
||||
},
|
||||
),
|
||||
description_placeholders={
|
||||
CONF_NAME: reauth_entry.title,
|
||||
"habiticans": HABITICANS_URL,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def validate_login(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> tuple[dict[str, str], LoginData | None, UserData | None]:
|
||||
"""Validate login with login credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(
|
||||
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
api = Habitica(session=session, x_client=X_CLIENT)
|
||||
try:
|
||||
login = await api.login(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
user = await api.get_user(user_fields="profile")
|
||||
|
||||
except NotAuthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except (HabiticaException, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return errors, login.data, user.data
|
||||
|
||||
return errors, None, None
|
||||
|
||||
async def validate_api_key(
|
||||
self, user_input: Mapping[str, Any]
|
||||
) -> tuple[dict[str, str], UserData | None]:
|
||||
"""Validate authentication with api key."""
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(
|
||||
self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True)
|
||||
)
|
||||
api = Habitica(
|
||||
session=session,
|
||||
x_client=X_CLIENT,
|
||||
api_user=user_input[CONF_API_USER],
|
||||
api_key=user_input[CONF_API_KEY],
|
||||
url=user_input.get(CONF_URL, DEFAULT_URL),
|
||||
)
|
||||
try:
|
||||
user = await api.get_user(user_fields="profile")
|
||||
except NotAuthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except (HabiticaException, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return errors, user.data
|
||||
|
||||
return errors, None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constants for the habitica integration."""
|
||||
|
||||
from homeassistant.const import CONF_PATH
|
||||
from homeassistant.const import APPLICATION_NAME, CONF_PATH, __version__
|
||||
|
||||
CONF_API_USER = "api_user"
|
||||
|
||||
@@ -44,9 +44,8 @@ SERVICE_SCORE_REWARD = "score_reward"
|
||||
SERVICE_TRANSFORMATION = "transformation"
|
||||
|
||||
|
||||
WARRIOR = "warrior"
|
||||
ROGUE = "rogue"
|
||||
HEALER = "healer"
|
||||
MAGE = "wizard"
|
||||
|
||||
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
|
||||
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
|
||||
|
||||
SECTION_REAUTH_LOGIN = "reauth_login"
|
||||
SECTION_REAUTH_API_KEY = "reauth_api_key"
|
||||
|
||||
@@ -5,16 +5,29 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from habitipy.aio import HabitipyAsync
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
ContentData,
|
||||
Habitica,
|
||||
HabiticaException,
|
||||
NotAuthorizedError,
|
||||
TaskData,
|
||||
TaskFilter,
|
||||
TooManyRequestsError,
|
||||
UserData,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -25,10 +38,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class HabiticaData:
|
||||
"""Coordinator data class."""
|
||||
"""Habitica data."""
|
||||
|
||||
user: dict[str, Any]
|
||||
tasks: list[dict]
|
||||
user: UserData
|
||||
tasks: list[TaskData]
|
||||
|
||||
|
||||
class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
@@ -36,7 +49,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, habitipy: HabitipyAsync) -> None:
|
||||
def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None:
|
||||
"""Initialize the Habitica data coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -50,25 +63,53 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
immediate=False,
|
||||
),
|
||||
)
|
||||
self.api = habitipy
|
||||
self.content: dict[str, Any] = {}
|
||||
self.habitica = habitica
|
||||
self.content: ContentData
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up Habitica integration."""
|
||||
|
||||
try:
|
||||
user = await self.habitica.get_user()
|
||||
self.content = (
|
||||
await self.habitica.get_content(user.data.preferences.language)
|
||||
).data
|
||||
except NotAuthorizedError as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from e
|
||||
except TooManyRequestsError as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
|
||||
if not self.config_entry.data.get(CONF_NAME):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={**self.config_entry.data, CONF_NAME: user.data.profile.name},
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> HabiticaData:
|
||||
try:
|
||||
user_response = await self.api.user.get()
|
||||
tasks_response = await self.api.tasks.user.get()
|
||||
tasks_response.extend(await self.api.tasks.user.get(type="completedTodos"))
|
||||
if not self.content:
|
||||
self.content = await self.api.content.get(
|
||||
language=user_response["preferences"]["language"]
|
||||
)
|
||||
except ClientResponseError as error:
|
||||
if error.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
_LOGGER.debug("Rate limit exceeded, will try again later")
|
||||
return self.data
|
||||
raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error
|
||||
|
||||
return HabiticaData(user=user_response, tasks=tasks_response)
|
||||
user = (await self.habitica.get_user()).data
|
||||
tasks = (await self.habitica.get_tasks()).data
|
||||
completed_todos = (
|
||||
await self.habitica.get_tasks(TaskFilter.COMPLETED_TODOS)
|
||||
).data
|
||||
except TooManyRequestsError:
|
||||
_LOGGER.debug("Rate limit exceeded, will try again later")
|
||||
return self.data
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise UpdateFailed(f"Unable to connect to Habitica: {e}") from e
|
||||
else:
|
||||
return HabiticaData(user=user, tasks=tasks + completed_todos)
|
||||
|
||||
async def execute(
|
||||
self, func: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
@@ -77,12 +118,12 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
|
||||
|
||||
try:
|
||||
await func(self)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
|
||||
@@ -16,12 +16,12 @@ async def async_get_config_entry_diagnostics(
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
habitica_data = await config_entry.runtime_data.api.user.anonymized.get()
|
||||
habitica_data = await config_entry.runtime_data.habitica.get_user_anonymized()
|
||||
|
||||
return {
|
||||
"config_entry_data": {
|
||||
CONF_URL: config_entry.data[CONF_URL],
|
||||
CONF_API_USER: config_entry.data[CONF_API_USER],
|
||||
},
|
||||
"habitica_data": habitica_data,
|
||||
"habitica_data": habitica_data.to_dict()["data"],
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["habitipy", "plumbum"],
|
||||
"requirements": ["habitipy==0.3.3"]
|
||||
"loggers": ["habiticalib"],
|
||||
"requirements": ["habiticalib==0.3.2"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -3,11 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from habiticalib import (
|
||||
ContentData,
|
||||
HabiticaClass,
|
||||
TaskData,
|
||||
TaskType,
|
||||
UserData,
|
||||
deserialize_task,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorDeviceClass,
|
||||
@@ -36,10 +45,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class HabitipySensorEntityDescription(SensorEntityDescription):
|
||||
"""Habitipy Sensor Description."""
|
||||
|
||||
value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType]
|
||||
attributes_fn: (
|
||||
Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None
|
||||
) = None
|
||||
value_fn: Callable[[UserData, ContentData], StateType]
|
||||
attributes_fn: Callable[[UserData, ContentData], dict[str, Any] | None] | None = (
|
||||
None
|
||||
)
|
||||
entity_picture: str | None = None
|
||||
|
||||
|
||||
@@ -47,7 +56,7 @@ class HabitipySensorEntityDescription(SensorEntityDescription):
|
||||
class HabitipyTaskSensorEntityDescription(SensorEntityDescription):
|
||||
"""Habitipy Task Sensor Description."""
|
||||
|
||||
value_fn: Callable[[list[dict[str, Any]]], list[dict[str, Any]]]
|
||||
value_fn: Callable[[list[TaskData]], list[TaskData]]
|
||||
|
||||
|
||||
class HabitipySensorEntity(StrEnum):
|
||||
@@ -79,75 +88,70 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.DISPLAY_NAME,
|
||||
translation_key=HabitipySensorEntity.DISPLAY_NAME,
|
||||
value_fn=lambda user, _: user.get("profile", {}).get("name"),
|
||||
value_fn=lambda user, _: user.profile.name,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.HEALTH,
|
||||
translation_key=HabitipySensorEntity.HEALTH,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("hp"),
|
||||
value_fn=lambda user, _: user.stats.hp,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.HEALTH_MAX,
|
||||
translation_key=HabitipySensorEntity.HEALTH_MAX,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"),
|
||||
value_fn=lambda user, _: 50,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.MANA,
|
||||
translation_key=HabitipySensorEntity.MANA,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("mp"),
|
||||
value_fn=lambda user, _: user.stats.mp,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.MANA_MAX,
|
||||
translation_key=HabitipySensorEntity.MANA_MAX,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("maxMP"),
|
||||
value_fn=lambda user, _: user.stats.maxMP,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.EXPERIENCE,
|
||||
translation_key=HabitipySensorEntity.EXPERIENCE,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("exp"),
|
||||
value_fn=lambda user, _: user.stats.exp,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.EXPERIENCE_MAX,
|
||||
translation_key=HabitipySensorEntity.EXPERIENCE_MAX,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"),
|
||||
value_fn=lambda user, _: user.stats.toNextLevel,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.LEVEL,
|
||||
translation_key=HabitipySensorEntity.LEVEL,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("lvl"),
|
||||
value_fn=lambda user, _: user.stats.lvl,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.GOLD,
|
||||
translation_key=HabitipySensorEntity.GOLD,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("gp"),
|
||||
value_fn=lambda user, _: user.stats.gp,
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.CLASS,
|
||||
translation_key=HabitipySensorEntity.CLASS,
|
||||
value_fn=lambda user, _: user.get("stats", {}).get("class"),
|
||||
value_fn=lambda user, _: user.stats.Class.value if user.stats.Class else None,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["warrior", "healer", "wizard", "rogue"],
|
||||
options=[item.value for item in HabiticaClass],
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.GEMS,
|
||||
translation_key=HabitipySensorEntity.GEMS,
|
||||
value_fn=lambda user, _: user.get("balance", 0) * 4,
|
||||
value_fn=lambda user, _: round(user.balance * 4) if user.balance else None,
|
||||
suggested_display_precision=0,
|
||||
entity_picture="shop_gem.png",
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.TRINKETS,
|
||||
translation_key=HabitipySensorEntity.TRINKETS,
|
||||
value_fn=(
|
||||
lambda user, _: user.get("purchased", {})
|
||||
.get("plan", {})
|
||||
.get("consecutive", {})
|
||||
.get("trinkets", 0)
|
||||
),
|
||||
value_fn=lambda user, _: user.purchased.plan.consecutive.trinkets or 0,
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="⧖",
|
||||
entity_picture="notif_subscriber_reward.png",
|
||||
@@ -155,16 +159,16 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = (
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.STRENGTH,
|
||||
translation_key=HabitipySensorEntity.STRENGTH,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "str"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "str"),
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "Str"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "Str"),
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="STR",
|
||||
),
|
||||
HabitipySensorEntityDescription(
|
||||
key=HabitipySensorEntity.INTELLIGENCE,
|
||||
translation_key=HabitipySensorEntity.INTELLIGENCE,
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "int"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "int"),
|
||||
value_fn=lambda user, content: get_attributes_total(user, content, "Int"),
|
||||
attributes_fn=lambda user, content: get_attribute_points(user, content, "Int"),
|
||||
suggested_display_precision=0,
|
||||
native_unit_of_measurement="INT",
|
||||
),
|
||||
@@ -203,7 +207,7 @@ TASKS_MAP = {
|
||||
"yester_daily": "yesterDaily",
|
||||
"completed": "completed",
|
||||
"collapse_checklist": "collapseChecklist",
|
||||
"type": "type",
|
||||
"type": "Type",
|
||||
"notes": "notes",
|
||||
"tags": "tags",
|
||||
"value": "value",
|
||||
@@ -221,26 +225,28 @@ TASK_SENSOR_DESCRIPTION: tuple[HabitipyTaskSensorEntityDescription, ...] = (
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.HABITS,
|
||||
translation_key=HabitipySensorEntity.HABITS,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "habit"],
|
||||
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT],
|
||||
),
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.DAILIES,
|
||||
translation_key=HabitipySensorEntity.DAILIES,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "daily"],
|
||||
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.DAILY],
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.TODOS,
|
||||
translation_key=HabitipySensorEntity.TODOS,
|
||||
value_fn=lambda tasks: [
|
||||
r for r in tasks if r.get("type") == "todo" and not r.get("completed")
|
||||
],
|
||||
value_fn=(
|
||||
lambda tasks: [
|
||||
r for r in tasks if r.Type is TaskType.TODO and not r.completed
|
||||
]
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
HabitipyTaskSensorEntityDescription(
|
||||
key=HabitipySensorEntity.REWARDS,
|
||||
translation_key=HabitipySensorEntity.REWARDS,
|
||||
value_fn=lambda tasks: [r for r in tasks if r.get("type") == "reward"],
|
||||
value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -309,15 +315,14 @@ class HabitipyTaskSensor(HabiticaBase, SensorEntity):
|
||||
attrs = {}
|
||||
|
||||
# Map tasks to TASKS_MAP
|
||||
for received_task in self.entity_description.value_fn(
|
||||
self.coordinator.data.tasks
|
||||
):
|
||||
for task_data in self.entity_description.value_fn(self.coordinator.data.tasks):
|
||||
received_task = deserialize_task(asdict(task_data))
|
||||
task_id = received_task[TASKS_MAP_ID]
|
||||
task = {}
|
||||
for map_key, map_value in TASKS_MAP.items():
|
||||
if value := received_task.get(map_value):
|
||||
task[map_key] = value
|
||||
attrs[task_id] = task
|
||||
attrs[str(task_id)] = task
|
||||
return attrs
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import (
|
||||
Direction,
|
||||
HabiticaException,
|
||||
NotAuthorizedError,
|
||||
NotFoundError,
|
||||
Skill,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -88,6 +96,25 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
SKILL_MAP = {
|
||||
"pickpocket": Skill.PICKPOCKET,
|
||||
"backstab": Skill.BACKSTAB,
|
||||
"smash": Skill.BRUTAL_SMASH,
|
||||
"fireball": Skill.BURST_OF_FLAMES,
|
||||
}
|
||||
COST_MAP = {
|
||||
"pickpocket": "10 MP",
|
||||
"backstab": "15 MP",
|
||||
"smash": "10 MP",
|
||||
"fireball": "10 MP",
|
||||
}
|
||||
ITEMID_MAP = {
|
||||
"snowball": Skill.SNOWBALL,
|
||||
"spooky_sparkles": Skill.SPOOKY_SPARKLES,
|
||||
"seafoam": Skill.SEAFOAM,
|
||||
"shiny_seed": Skill.SHINY_SEED,
|
||||
}
|
||||
|
||||
|
||||
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
@@ -123,12 +150,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
|
||||
name = call.data[ATTR_NAME]
|
||||
path = call.data[ATTR_PATH]
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
entries: list[HabiticaConfigEntry] = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
api = None
|
||||
for entry in entries:
|
||||
if entry.data[CONF_NAME] == name:
|
||||
api = entry.runtime_data.api
|
||||
api = await entry.runtime_data.habitica.habitipy()
|
||||
break
|
||||
if api is None:
|
||||
_LOGGER.error("API_CALL: User '%s' not configured", name)
|
||||
@@ -151,18 +178,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
"""Skill action."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
skill = {
|
||||
"pickpocket": {"spellId": "pickPocket", "cost": "10 MP"},
|
||||
"backstab": {"spellId": "backStab", "cost": "15 MP"},
|
||||
"smash": {"spellId": "smash", "cost": "10 MP"},
|
||||
"fireball": {"spellId": "fireball", "cost": "10 MP"},
|
||||
}
|
||||
|
||||
skill = SKILL_MAP[call.data[ATTR_SKILL]]
|
||||
cost = COST_MAP[call.data[ATTR_SKILL]]
|
||||
|
||||
try:
|
||||
task_id = next(
|
||||
task["id"]
|
||||
task.id
|
||||
for task in coordinator.data.tasks
|
||||
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
|
||||
or call.data[ATTR_TASK] == task["text"]
|
||||
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
@@ -172,75 +196,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
) from e
|
||||
|
||||
try:
|
||||
response: dict[str, Any] = await coordinator.api.user.class_.cast[
|
||||
skill[call.data[ATTR_SKILL]]["spellId"]
|
||||
].post(targetId=task_id)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_enough_mana",
|
||||
translation_placeholders={
|
||||
"cost": skill[call.data[ATTR_SKILL]]["cost"],
|
||||
"mana": f"{int(coordinator.data.user.get("stats", {}).get("mp", 0))} MP",
|
||||
},
|
||||
) from e
|
||||
if e.status == HTTPStatus.NOT_FOUND:
|
||||
# could also be task not found, but the task is looked up
|
||||
# before the request, so most likely wrong skill selected
|
||||
# or the skill hasn't been unlocked yet.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="skill_not_found",
|
||||
translation_placeholders={"skill": call.data[ATTR_SKILL]},
|
||||
) from e
|
||||
response = await coordinator.habitica.cast_skill(skill, task_id)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_enough_mana",
|
||||
translation_placeholders={
|
||||
"cost": cost,
|
||||
"mana": f"{int(coordinator.data.user.stats.mp or 0)} MP",
|
||||
},
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
# could also be task not found, but the task is looked up
|
||||
# before the request, so most likely wrong skill selected
|
||||
# or the skill hasn't been unlocked yet.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="skill_not_found",
|
||||
translation_placeholders={"skill": call.data[ATTR_SKILL]},
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
else:
|
||||
await coordinator.async_request_refresh()
|
||||
return response
|
||||
return asdict(response.data)
|
||||
|
||||
async def manage_quests(call: ServiceCall) -> ServiceResponse:
|
||||
"""Accept, reject, start, leave or cancel quests."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
COMMAND_MAP = {
|
||||
SERVICE_ABORT_QUEST: "abort",
|
||||
SERVICE_ACCEPT_QUEST: "accept",
|
||||
SERVICE_CANCEL_QUEST: "cancel",
|
||||
SERVICE_LEAVE_QUEST: "leave",
|
||||
SERVICE_REJECT_QUEST: "reject",
|
||||
SERVICE_START_QUEST: "force-start",
|
||||
FUNC_MAP = {
|
||||
SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest,
|
||||
SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest,
|
||||
SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest,
|
||||
SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest,
|
||||
SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest,
|
||||
SERVICE_START_QUEST: coordinator.habitica.start_quest,
|
||||
}
|
||||
|
||||
func = FUNC_MAP[call.service]
|
||||
|
||||
try:
|
||||
return await coordinator.api.groups.party.quests[
|
||||
COMMAND_MAP[call.service]
|
||||
].post()
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
|
||||
) from e
|
||||
if e.status == HTTPStatus.NOT_FOUND:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_not_found"
|
||||
) from e
|
||||
response = await func()
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_action_unallowed"
|
||||
) from e
|
||||
except NotFoundError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="quest_not_found"
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_call_exception"
|
||||
) from e
|
||||
else:
|
||||
return asdict(response.data)
|
||||
|
||||
for service in (
|
||||
SERVICE_ABORT_QUEST,
|
||||
@@ -262,12 +287,15 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
"""Score a task action."""
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
direction = (
|
||||
Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP
|
||||
)
|
||||
try:
|
||||
task_id, task_value = next(
|
||||
(task["id"], task.get("value"))
|
||||
(task.id, task.value)
|
||||
for task in coordinator.data.tasks
|
||||
if call.data[ATTR_TASK] in (task["id"], task.get("alias"))
|
||||
or call.data[ATTR_TASK] == task["text"]
|
||||
if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
@@ -276,81 +304,76 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
|
||||
) from e
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert task_id
|
||||
try:
|
||||
response: dict[str, Any] = (
|
||||
await coordinator.api.tasks[task_id]
|
||||
.score[call.data.get(ATTR_DIRECTION, "up")]
|
||||
.post()
|
||||
)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None:
|
||||
response = await coordinator.habitica.update_score(task_id, direction)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
if task_value is not None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_enough_gold",
|
||||
translation_placeholders={
|
||||
"gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP",
|
||||
"cost": f"{task_value} GP",
|
||||
"gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP",
|
||||
"cost": f"{task_value:.2f} GP",
|
||||
},
|
||||
) from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
else:
|
||||
await coordinator.async_request_refresh()
|
||||
return response
|
||||
return asdict(response.data)
|
||||
|
||||
async def transformation(call: ServiceCall) -> ServiceResponse:
|
||||
"""User a transformation item on a player character."""
|
||||
|
||||
entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
|
||||
coordinator = entry.runtime_data
|
||||
ITEMID_MAP = {
|
||||
"snowball": {"itemId": "snowball"},
|
||||
"spooky_sparkles": {"itemId": "spookySparkles"},
|
||||
"seafoam": {"itemId": "seafoam"},
|
||||
"shiny_seed": {"itemId": "shinySeed"},
|
||||
}
|
||||
|
||||
item = ITEMID_MAP[call.data[ATTR_ITEM]]
|
||||
# check if target is self
|
||||
if call.data[ATTR_TARGET] in (
|
||||
coordinator.data.user["id"],
|
||||
coordinator.data.user["profile"]["name"],
|
||||
coordinator.data.user["auth"]["local"]["username"],
|
||||
str(coordinator.data.user.id),
|
||||
coordinator.data.user.profile.name,
|
||||
coordinator.data.user.auth.local.username,
|
||||
):
|
||||
target_id = coordinator.data.user["id"]
|
||||
target_id = coordinator.data.user.id
|
||||
else:
|
||||
# check if target is a party member
|
||||
try:
|
||||
party = await coordinator.api.groups.party.members.get()
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.NOT_FOUND:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="party_not_found",
|
||||
) from e
|
||||
party = await coordinator.habitica.get_group_members(public_fields=True)
|
||||
except NotFoundError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="party_not_found",
|
||||
) from e
|
||||
except (ClientError, HabiticaException) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
try:
|
||||
target_id = next(
|
||||
member["id"]
|
||||
for member in party
|
||||
if call.data[ATTR_TARGET].lower()
|
||||
member.id
|
||||
for member in party.data
|
||||
if member.id
|
||||
and call.data[ATTR_TARGET].lower()
|
||||
in (
|
||||
member["id"],
|
||||
member["auth"]["local"]["username"].lower(),
|
||||
member["profile"]["name"].lower(),
|
||||
str(member.id),
|
||||
str(member.auth.local.username).lower(),
|
||||
str(member.profile.name).lower(),
|
||||
)
|
||||
)
|
||||
except StopIteration as e:
|
||||
@@ -360,27 +383,25 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
|
||||
translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"},
|
||||
) from e
|
||||
try:
|
||||
response: dict[str, Any] = await coordinator.api.user.class_.cast[
|
||||
ITEMID_MAP[call.data[ATTR_ITEM]]["itemId"]
|
||||
].post(targetId=target_id)
|
||||
except ClientResponseError as e:
|
||||
if e.status == HTTPStatus.TOO_MANY_REQUESTS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
if e.status == HTTPStatus.UNAUTHORIZED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="item_not_found",
|
||||
translation_placeholders={"item": call.data[ATTR_ITEM]},
|
||||
) from e
|
||||
response = await coordinator.habitica.cast_skill(item, target_id)
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_rate_limit_exception",
|
||||
) from e
|
||||
except NotAuthorizedError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="item_not_found",
|
||||
translation_placeholders={"item": call.data[ATTR_ITEM]},
|
||||
) from e
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_call_exception",
|
||||
) from e
|
||||
else:
|
||||
return response
|
||||
return asdict(response.data)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"unique_id_mismatch": "Hmm, those login details are correct, but they're not for this adventurer. Got another account to try?",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"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%]",
|
||||
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -49,9 +52,38 @@
|
||||
"data_description": {
|
||||
"url": "URL of the Habitica installation to connect to. Defaults to `{default_url}`",
|
||||
"api_user": "User ID of your Habitica account",
|
||||
"api_key": "API Token of the Habitica account"
|
||||
"api_key": "API Token of the Habitica account",
|
||||
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to a Habitica instance using a self-signed certificate"
|
||||
},
|
||||
"description": "You can retrieve your `User ID` and `API Token` from [**Settings -> Site Data**]({site_data}) on Habitica or the instance you want to connect to"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authorize {name} with Habitica",
|
||||
"description": " It seems your API token for **{name}** has been reset. To re-authorize the integration, you can either log in with your username or email, and password, or directly provide your new API token.",
|
||||
"sections": {
|
||||
"reauth_login": {
|
||||
"name": "Re-authorize via login",
|
||||
"description": "Enter your login details below to re-authorize the Home Assistant integration with Habitica",
|
||||
"data": {
|
||||
"username": "[%key:component::habitica::config::step::login::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::habitica::config::step::login::data_description::username%]",
|
||||
"password": "[%key:component::habitica::config::step::login::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"reauth_api_key": {
|
||||
"description": "Enter your new API token below. You can find it in Habitica under 'Settings -> Site Data'",
|
||||
"name": "Re-authorize via API Token",
|
||||
"data": {
|
||||
"api_key": "[%key:component::habitica::config::step::advanced::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::habitica::config::step::advanced::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -365,6 +397,9 @@
|
||||
},
|
||||
"item_not_found": {
|
||||
"message": "Unable to use {item}, you don't own this item."
|
||||
},
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -28,7 +28,7 @@ class HabiticaSwitchEntityDescription(SwitchEntityDescription):
|
||||
|
||||
turn_on_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
turn_off_fn: Callable[[HabiticaDataUpdateCoordinator], Any]
|
||||
is_on_fn: Callable[[HabiticaData], bool]
|
||||
is_on_fn: Callable[[HabiticaData], bool | None]
|
||||
|
||||
|
||||
class HabiticaSwitchEntity(StrEnum):
|
||||
@@ -42,9 +42,9 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
|
||||
key=HabiticaSwitchEntity.SLEEP,
|
||||
translation_key=HabiticaSwitchEntity.SLEEP,
|
||||
device_class=SwitchDeviceClass.SWITCH,
|
||||
turn_on_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
|
||||
turn_off_fn=lambda coordinator: coordinator.api["user"]["sleep"].post(),
|
||||
is_on_fn=lambda data: data.user["preferences"]["sleep"],
|
||||
turn_on_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
|
||||
turn_off_fn=lambda coordinator: coordinator.habitica.toggle_sleep(),
|
||||
is_on_fn=lambda data: data.user.preferences.sleep,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from aiohttp import ClientError
|
||||
from habiticalib import Direction, HabiticaException, Task, TaskType
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.components.todo import (
|
||||
@@ -24,7 +25,7 @@ from homeassistant.util import dt as dt_util
|
||||
from .const import ASSETS_URL, DOMAIN
|
||||
from .coordinator import HabiticaDataUpdateCoordinator
|
||||
from .entity import HabiticaBase
|
||||
from .types import HabiticaConfigEntry, HabiticaTaskType
|
||||
from .types import HabiticaConfigEntry
|
||||
from .util import next_due_date
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
@@ -70,8 +71,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
"""Delete Habitica tasks."""
|
||||
if len(uids) > 1 and self.entity_description.key is HabiticaTodoList.TODOS:
|
||||
try:
|
||||
await self.coordinator.api.tasks.clearCompletedTodos.post()
|
||||
except ClientResponseError as e:
|
||||
await self.coordinator.habitica.delete_completed_todos()
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="delete_completed_todos_failed",
|
||||
@@ -79,8 +80,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
else:
|
||||
for task_id in uids:
|
||||
try:
|
||||
await self.coordinator.api.tasks[task_id].delete()
|
||||
except ClientResponseError as e:
|
||||
await self.coordinator.habitica.delete_task(UUID(task_id))
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"delete_{self.entity_description.key}_failed",
|
||||
@@ -106,9 +107,8 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
pos = 0
|
||||
|
||||
try:
|
||||
await self.coordinator.api.tasks[uid].move.to[str(pos)].post()
|
||||
|
||||
except ClientResponseError as e:
|
||||
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"move_{self.entity_description.key}_item_failed",
|
||||
@@ -118,12 +118,14 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
# move tasks in the coordinator until we have fresh data
|
||||
tasks = self.coordinator.data.tasks
|
||||
new_pos = (
|
||||
tasks.index(next(task for task in tasks if task["id"] == previous_uid))
|
||||
tasks.index(
|
||||
next(task for task in tasks if task.id == UUID(previous_uid))
|
||||
)
|
||||
+ 1
|
||||
if previous_uid
|
||||
else 0
|
||||
)
|
||||
old_pos = tasks.index(next(task for task in tasks if task["id"] == uid))
|
||||
old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
|
||||
tasks.insert(new_pos, tasks.pop(old_pos))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -138,14 +140,17 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
if TYPE_CHECKING:
|
||||
assert item.uid
|
||||
assert current_item
|
||||
assert item.summary
|
||||
|
||||
task = Task(
|
||||
text=item.summary,
|
||||
notes=item.description or "",
|
||||
)
|
||||
|
||||
if (
|
||||
self.entity_description.key is HabiticaTodoList.TODOS
|
||||
and item.due is not None
|
||||
): # Only todos support a due date.
|
||||
date = item.due.isoformat()
|
||||
else:
|
||||
date = None
|
||||
task["date"] = item.due
|
||||
|
||||
if (
|
||||
item.summary != current_item.summary
|
||||
@@ -153,13 +158,9 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
or item.due != current_item.due
|
||||
):
|
||||
try:
|
||||
await self.coordinator.api.tasks[item.uid].put(
|
||||
text=item.summary,
|
||||
notes=item.description or "",
|
||||
date=date,
|
||||
)
|
||||
await self.coordinator.habitica.update_task(UUID(item.uid), task)
|
||||
refresh_required = True
|
||||
except ClientResponseError as e:
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"update_{self.entity_description.key}_item_failed",
|
||||
@@ -172,32 +173,33 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
current_item.status is TodoItemStatus.NEEDS_ACTION
|
||||
and item.status == TodoItemStatus.COMPLETED
|
||||
):
|
||||
score_result = (
|
||||
await self.coordinator.api.tasks[item.uid].score["up"].post()
|
||||
score_result = await self.coordinator.habitica.update_score(
|
||||
UUID(item.uid), Direction.UP
|
||||
)
|
||||
refresh_required = True
|
||||
elif (
|
||||
current_item.status is TodoItemStatus.COMPLETED
|
||||
and item.status == TodoItemStatus.NEEDS_ACTION
|
||||
):
|
||||
score_result = (
|
||||
await self.coordinator.api.tasks[item.uid].score["down"].post()
|
||||
score_result = await self.coordinator.habitica.update_score(
|
||||
UUID(item.uid), Direction.DOWN
|
||||
)
|
||||
refresh_required = True
|
||||
else:
|
||||
score_result = None
|
||||
|
||||
except ClientResponseError as e:
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"score_{self.entity_description.key}_item_failed",
|
||||
translation_placeholders={"name": item.summary or ""},
|
||||
) from e
|
||||
|
||||
if score_result and (drop := score_result.get("_tmp", {}).get("drop", False)):
|
||||
if score_result and score_result.data.tmp.drop.key:
|
||||
drop = score_result.data.tmp.drop
|
||||
msg = (
|
||||
f"![{drop["key"]}]({ASSETS_URL}Pet_{drop["type"]}_{drop["key"]}.png)\n"
|
||||
f"{drop["dialog"]}"
|
||||
f"\n"
|
||||
f"{drop.dialog}"
|
||||
)
|
||||
persistent_notification.async_create(
|
||||
self.hass, message=msg, title="Habitica"
|
||||
@@ -229,38 +231,36 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
return [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=task["id"],
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
due=(
|
||||
dt_util.as_local(
|
||||
datetime.datetime.fromisoformat(task["date"])
|
||||
).date()
|
||||
if task.get("date")
|
||||
else None
|
||||
),
|
||||
uid=str(task.id),
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
due=dt_util.as_local(task.date).date() if task.date else None,
|
||||
status=(
|
||||
TodoItemStatus.NEEDS_ACTION
|
||||
if not task["completed"]
|
||||
if not task.completed
|
||||
else TodoItemStatus.COMPLETED
|
||||
),
|
||||
)
|
||||
for task in self.coordinator.data.tasks
|
||||
if task["type"] == HabiticaTaskType.TODO
|
||||
if task.Type is TaskType.TODO
|
||||
),
|
||||
]
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Create a Habitica todo."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert item.summary
|
||||
assert item.description
|
||||
try:
|
||||
await self.coordinator.api.tasks.user.post(
|
||||
text=item.summary,
|
||||
type=HabiticaTaskType.TODO,
|
||||
notes=item.description,
|
||||
date=item.due.isoformat() if item.due else None,
|
||||
await self.coordinator.habitica.create_task(
|
||||
Task(
|
||||
text=item.summary,
|
||||
type=TaskType.TODO,
|
||||
notes=item.description,
|
||||
date=item.due,
|
||||
)
|
||||
)
|
||||
except ClientResponseError as e:
|
||||
except (HabiticaException, ClientError) as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=f"create_{self.entity_description.key}_item_failed",
|
||||
@@ -295,23 +295,23 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
that have been completed but forgotten to mark as completed before resetting the dailies.
|
||||
Changes of the date input field in Home Assistant will be ignored.
|
||||
"""
|
||||
|
||||
last_cron = self.coordinator.data.user["lastCron"]
|
||||
if TYPE_CHECKING:
|
||||
assert self.coordinator.data.user.lastCron
|
||||
|
||||
return [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=task["id"],
|
||||
summary=task["text"],
|
||||
description=task["notes"],
|
||||
due=next_due_date(task, last_cron),
|
||||
uid=str(task.id),
|
||||
summary=task.text,
|
||||
description=task.notes,
|
||||
due=next_due_date(task, self.coordinator.data.user.lastCron),
|
||||
status=(
|
||||
TodoItemStatus.COMPLETED
|
||||
if task["completed"]
|
||||
if task.completed
|
||||
else TodoItemStatus.NEEDS_ACTION
|
||||
),
|
||||
)
|
||||
for task in self.coordinator.data.tasks
|
||||
if task["type"] == HabiticaTaskType.DAILY
|
||||
if task.Type is TaskType.DAILY
|
||||
)
|
||||
]
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import fields
|
||||
import datetime
|
||||
from math import floor
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dateutil.rrule import (
|
||||
DAILY,
|
||||
@@ -20,6 +21,7 @@ from dateutil.rrule import (
|
||||
YEARLY,
|
||||
rrule,
|
||||
)
|
||||
from habiticalib import ContentData, Frequency, TaskData, UserData
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
@@ -27,50 +29,32 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None:
|
||||
def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | None:
|
||||
"""Calculate due date for dailies and yesterdailies."""
|
||||
|
||||
if task["everyX"] == 0 or not task.get("nextDue"): # grey dailies never become due
|
||||
if task.everyX == 0 or not task.nextDue: # grey dailies never become due
|
||||
return None
|
||||
|
||||
today = to_date(last_cron)
|
||||
startdate = to_date(task["startDate"])
|
||||
if TYPE_CHECKING:
|
||||
assert today
|
||||
assert startdate
|
||||
assert task.startDate
|
||||
|
||||
if task["isDue"] and not task["completed"]:
|
||||
return to_date(last_cron)
|
||||
if task.isDue is True and not task.completed:
|
||||
return dt_util.as_local(today).date()
|
||||
|
||||
if startdate > today:
|
||||
if task["frequency"] == "daily" or (
|
||||
task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"]
|
||||
if task.startDate > today:
|
||||
if task.frequency is Frequency.DAILY or (
|
||||
task.frequency in (Frequency.MONTHLY, Frequency.YEARLY) and task.daysOfMonth
|
||||
):
|
||||
return startdate
|
||||
return dt_util.as_local(task.startDate).date()
|
||||
|
||||
if (
|
||||
task["frequency"] in ("weekly", "monthly")
|
||||
and (nextdue := to_date(task["nextDue"][0]))
|
||||
and startdate > nextdue
|
||||
task.frequency in (Frequency.WEEKLY, Frequency.MONTHLY)
|
||||
and (nextdue := task.nextDue[0])
|
||||
and task.startDate > nextdue
|
||||
):
|
||||
return to_date(task["nextDue"][1])
|
||||
return dt_util.as_local(task.nextDue[1]).date()
|
||||
|
||||
return to_date(task["nextDue"][0])
|
||||
|
||||
|
||||
def to_date(date: str) -> datetime.date | None:
|
||||
"""Convert an iso date to a datetime.date object."""
|
||||
try:
|
||||
return dt_util.as_local(datetime.datetime.fromisoformat(date)).date()
|
||||
except ValueError:
|
||||
# sometimes nextDue dates are JavaScript datetime strings instead of iso:
|
||||
# "Mon May 06 2024 00:00:00 GMT+0200"
|
||||
try:
|
||||
return dt_util.as_local(
|
||||
datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z")
|
||||
).date()
|
||||
except ValueError:
|
||||
return None
|
||||
return dt_util.as_local(task.nextDue[0]).date()
|
||||
|
||||
|
||||
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
|
||||
@@ -84,30 +68,27 @@ FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly":
|
||||
WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU}
|
||||
|
||||
|
||||
def build_rrule(task: dict[str, Any]) -> rrule:
|
||||
def build_rrule(task: TaskData) -> rrule:
|
||||
"""Build rrule string."""
|
||||
|
||||
rrule_frequency = FREQUENCY_MAP.get(task["frequency"], DAILY)
|
||||
weekdays = [
|
||||
WEEKDAY_MAP[day] for day, is_active in task["repeat"].items() if is_active
|
||||
]
|
||||
if TYPE_CHECKING:
|
||||
assert task.frequency
|
||||
assert task.everyX
|
||||
rrule_frequency = FREQUENCY_MAP.get(task.frequency, DAILY)
|
||||
weekdays = [day for key, day in WEEKDAY_MAP.items() if getattr(task.repeat, key)]
|
||||
bymonthday = (
|
||||
task["daysOfMonth"]
|
||||
if rrule_frequency == MONTHLY and task["daysOfMonth"]
|
||||
else None
|
||||
task.daysOfMonth if rrule_frequency == MONTHLY and task.daysOfMonth else None
|
||||
)
|
||||
|
||||
bysetpos = None
|
||||
if rrule_frequency == MONTHLY and task["weeksOfMonth"]:
|
||||
bysetpos = task["weeksOfMonth"]
|
||||
if rrule_frequency == MONTHLY and task.weeksOfMonth:
|
||||
bysetpos = task.weeksOfMonth
|
||||
weekdays = weekdays if weekdays else [MO]
|
||||
|
||||
return rrule(
|
||||
freq=rrule_frequency,
|
||||
interval=task["everyX"],
|
||||
dtstart=dt_util.start_of_local_day(
|
||||
datetime.datetime.fromisoformat(task["startDate"])
|
||||
),
|
||||
interval=task.everyX,
|
||||
dtstart=dt_util.start_of_local_day(task.startDate),
|
||||
byweekday=weekdays if rrule_frequency in [WEEKLY, MONTHLY] else None,
|
||||
bymonthday=bymonthday,
|
||||
bysetpos=bysetpos,
|
||||
@@ -143,48 +124,37 @@ def get_recurrence_rule(recurrence: rrule) -> str:
|
||||
|
||||
|
||||
def get_attribute_points(
|
||||
user: dict[str, Any], content: dict[str, Any], attribute: str
|
||||
user: UserData, content: ContentData, attribute: str
|
||||
) -> dict[str, float]:
|
||||
"""Get modifiers contributing to strength attribute."""
|
||||
|
||||
gear_set = {
|
||||
"weapon",
|
||||
"armor",
|
||||
"head",
|
||||
"shield",
|
||||
"back",
|
||||
"headAccessory",
|
||||
"eyewear",
|
||||
"body",
|
||||
}
|
||||
"""Get modifiers contributing to STR/INT/CON/PER attributes."""
|
||||
|
||||
equipment = sum(
|
||||
stats[attribute]
|
||||
for gear in gear_set
|
||||
if (equipped := user["items"]["gear"]["equipped"].get(gear))
|
||||
and (stats := content["gear"]["flat"].get(equipped))
|
||||
getattr(stats, attribute)
|
||||
for gear in fields(user.items.gear.equipped)
|
||||
if (equipped := getattr(user.items.gear.equipped, gear.name))
|
||||
and (stats := content.gear.flat[equipped])
|
||||
)
|
||||
|
||||
class_bonus = sum(
|
||||
stats[attribute] / 2
|
||||
for gear in gear_set
|
||||
if (equipped := user["items"]["gear"]["equipped"].get(gear))
|
||||
and (stats := content["gear"]["flat"].get(equipped))
|
||||
and stats["klass"] == user["stats"]["class"]
|
||||
getattr(stats, attribute) / 2
|
||||
for gear in fields(user.items.gear.equipped)
|
||||
if (equipped := getattr(user.items.gear.equipped, gear.name))
|
||||
and (stats := content.gear.flat[equipped])
|
||||
and stats.klass == user.stats.Class
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
assert user.stats.lvl
|
||||
|
||||
return {
|
||||
"level": min(floor(user["stats"]["lvl"] / 2), 50),
|
||||
"level": min(floor(user.stats.lvl / 2), 50),
|
||||
"equipment": equipment,
|
||||
"class": class_bonus,
|
||||
"allocated": user["stats"][attribute],
|
||||
"buffs": user["stats"]["buffs"][attribute],
|
||||
"allocated": getattr(user.stats, attribute),
|
||||
"buffs": getattr(user.stats.buffs, attribute),
|
||||
}
|
||||
|
||||
|
||||
def get_attributes_total(
|
||||
user: dict[str, Any], content: dict[str, Any], attribute: str
|
||||
) -> int:
|
||||
def get_attributes_total(user: UserData, content: ContentData, attribute: str) -> int:
|
||||
"""Get total attribute points."""
|
||||
return floor(
|
||||
sum(value for value in get_attribute_points(user, content, attribute).values())
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import Any, cast
|
||||
|
||||
from aiohasupervisor.exceptions import (
|
||||
SupervisorBadRequestError,
|
||||
SupervisorError,
|
||||
SupervisorNotFoundError,
|
||||
)
|
||||
from aiohasupervisor.models import (
|
||||
@@ -24,10 +23,8 @@ from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupReaderWriter,
|
||||
BackupReaderWriterError,
|
||||
CreateBackupEvent,
|
||||
Folder,
|
||||
IncorrectPasswordError,
|
||||
NewBackup,
|
||||
WrittenBackup,
|
||||
)
|
||||
@@ -216,10 +213,6 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
password: str | None,
|
||||
) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
|
||||
"""Create a backup."""
|
||||
if not include_homeassistant and include_database:
|
||||
raise HomeAssistantError(
|
||||
"Cannot create a backup with database but without Home Assistant"
|
||||
)
|
||||
manager = self._hass.data[DATA_MANAGER]
|
||||
|
||||
include_addons_set: supervisor_backups.AddonSet | set[str] | None = None
|
||||
@@ -240,23 +233,20 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
]
|
||||
locations = [agent.location for agent in hassio_agents]
|
||||
|
||||
try:
|
||||
backup = await self._client.backups.partial_backup(
|
||||
supervisor_backups.PartialBackupOptions(
|
||||
addons=include_addons_set,
|
||||
folders=include_folders_set,
|
||||
homeassistant=include_homeassistant,
|
||||
name=backup_name,
|
||||
password=password,
|
||||
compressed=True,
|
||||
location=locations or LOCATION_CLOUD_BACKUP,
|
||||
homeassistant_exclude_database=not include_database,
|
||||
background=True,
|
||||
extra=extra_metadata,
|
||||
)
|
||||
backup = await self._client.backups.partial_backup(
|
||||
supervisor_backups.PartialBackupOptions(
|
||||
addons=include_addons_set,
|
||||
folders=include_folders_set,
|
||||
homeassistant=include_homeassistant,
|
||||
name=backup_name,
|
||||
password=password,
|
||||
compressed=True,
|
||||
location=locations or LOCATION_CLOUD_BACKUP,
|
||||
homeassistant_exclude_database=not include_database,
|
||||
background=True,
|
||||
extra=extra_metadata,
|
||||
)
|
||||
except SupervisorError as err:
|
||||
raise BackupReaderWriterError(f"Error creating backup: {err}") from err
|
||||
)
|
||||
backup_task = self._hass.async_create_task(
|
||||
self._async_wait_for_backup(
|
||||
backup, remove_after_upload=not bool(locations)
|
||||
@@ -288,35 +278,22 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
finally:
|
||||
unsub()
|
||||
if not backup_id:
|
||||
raise BackupReaderWriterError("Backup failed")
|
||||
raise HomeAssistantError("Backup failed")
|
||||
|
||||
async def open_backup() -> AsyncIterator[bytes]:
|
||||
try:
|
||||
return await self._client.backups.download_backup(backup_id)
|
||||
except SupervisorError as err:
|
||||
raise BackupReaderWriterError(
|
||||
f"Error downloading backup: {err}"
|
||||
) from err
|
||||
return await self._client.backups.download_backup(backup_id)
|
||||
|
||||
async def remove_backup() -> None:
|
||||
if not remove_after_upload:
|
||||
return
|
||||
try:
|
||||
await self._client.backups.remove_backup(
|
||||
backup_id,
|
||||
options=supervisor_backups.RemoveBackupOptions(
|
||||
location={LOCATION_CLOUD_BACKUP}
|
||||
),
|
||||
)
|
||||
except SupervisorError as err:
|
||||
raise BackupReaderWriterError(f"Error removing backup: {err}") from err
|
||||
await self._client.backups.remove_backup(
|
||||
backup_id,
|
||||
options=supervisor_backups.RemoveBackupOptions(
|
||||
location={LOCATION_CLOUD_BACKUP}
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
except SupervisorError as err:
|
||||
raise BackupReaderWriterError(
|
||||
f"Error getting backup details: {err}"
|
||||
) from err
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
|
||||
return WrittenBackup(
|
||||
backup=_backup_details_to_agent_backup(details),
|
||||
@@ -382,16 +359,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
restore_homeassistant: bool,
|
||||
) -> None:
|
||||
"""Restore a backup."""
|
||||
manager = self._hass.data[DATA_MANAGER]
|
||||
# The backup manager has already checked that the backup exists so we don't need to
|
||||
# check that here.
|
||||
backup = await manager.backup_agents[agent_id].async_get_backup(backup_id)
|
||||
if (
|
||||
backup
|
||||
and restore_homeassistant
|
||||
and restore_database != backup.database_included
|
||||
):
|
||||
raise HomeAssistantError("Restore database must match backup")
|
||||
if restore_homeassistant and not restore_database:
|
||||
raise HomeAssistantError("Cannot restore Home Assistant without database")
|
||||
if not restore_homeassistant and restore_database:
|
||||
raise HomeAssistantError("Cannot restore database without Home Assistant")
|
||||
restore_addons_set = set(restore_addons) if restore_addons else None
|
||||
@@ -401,6 +370,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
else None
|
||||
)
|
||||
|
||||
manager = self._hass.data[DATA_MANAGER]
|
||||
restore_location: str | None
|
||||
if manager.backup_agents[agent_id].domain != DOMAIN:
|
||||
# Download the backup to the supervisor. Supervisor will clean up the backup
|
||||
@@ -415,24 +385,17 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
agent = cast(SupervisorBackupAgent, manager.backup_agents[agent_id])
|
||||
restore_location = agent.location
|
||||
|
||||
try:
|
||||
job = await self._client.backups.partial_restore(
|
||||
backup_id,
|
||||
supervisor_backups.PartialRestoreOptions(
|
||||
addons=restore_addons_set,
|
||||
folders=restore_folders_set,
|
||||
homeassistant=restore_homeassistant,
|
||||
password=password,
|
||||
background=True,
|
||||
location=restore_location,
|
||||
),
|
||||
)
|
||||
except SupervisorBadRequestError as err:
|
||||
# Supervisor currently does not transmit machine parsable error types
|
||||
message = err.args[0]
|
||||
if message.startswith("Invalid password for backup"):
|
||||
raise IncorrectPasswordError(message) from err
|
||||
raise HomeAssistantError(message) from err
|
||||
job = await self._client.backups.partial_restore(
|
||||
backup_id,
|
||||
supervisor_backups.PartialRestoreOptions(
|
||||
addons=restore_addons_set,
|
||||
folders=restore_folders_set,
|
||||
homeassistant=restore_homeassistant,
|
||||
password=password,
|
||||
background=True,
|
||||
location=restore_location,
|
||||
),
|
||||
)
|
||||
|
||||
restore_complete = asyncio.Event()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyheos import Heos, HeosError, HeosPlayer, const as heos_const
|
||||
from pyheos import Heos, HeosError, HeosOptions, HeosPlayer, const as heos_const
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
@@ -58,9 +58,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
|
||||
host = entry.data[CONF_HOST]
|
||||
# Setting all_progress_events=False ensures that we only receive a
|
||||
# media position update upon start of playback or when media changes
|
||||
controller = Heos(host, all_progress_events=False)
|
||||
controller = Heos(HeosOptions(host, all_progress_events=False, auto_reconnect=True))
|
||||
try:
|
||||
await controller.connect(auto_reconnect=True)
|
||||
await controller.connect()
|
||||
# Auto reconnect only operates if initial connection was successful.
|
||||
except HeosError as error:
|
||||
await controller.disconnect()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pyheos import Heos, HeosError
|
||||
from pyheos import Heos, HeosError, HeosOptions
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ssdp
|
||||
@@ -20,7 +20,7 @@ def format_title(host: str) -> str:
|
||||
|
||||
async def _validate_host(host: str, errors: dict[str, str]) -> bool:
|
||||
"""Validate host is reachable, return True, otherwise populate errors and return False."""
|
||||
heos = Heos(host)
|
||||
heos = Heos(HeosOptions(host, events=False, heart_beat=False))
|
||||
try:
|
||||
await heos.connect()
|
||||
except HeosError:
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/heos",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyheos"],
|
||||
"requirements": ["pyheos==0.7.2"],
|
||||
"requirements": ["pyheos==0.8.0"],
|
||||
"single_config_entry": true,
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -114,7 +114,6 @@ class HiveDeviceLight(HiveEntity, LightEntity):
|
||||
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb)
|
||||
self._attr_color_mode = ColorMode.HS
|
||||
else:
|
||||
color_temp = self.device["status"].get("color_temp")
|
||||
self._attr_color_temp_kelvin = (
|
||||
None
|
||||
if color_temp is None
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.64", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.63", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ async def _run_appliance_service[*_Ts](
|
||||
error_translation_placeholders: dict[str, str],
|
||||
) -> None:
|
||||
try:
|
||||
await hass.async_add_executor_job(getattr(appliance, method), *args)
|
||||
await hass.async_add_executor_job(getattr(appliance, method), args)
|
||||
except api.HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -220,7 +220,7 @@ async def async_setup_entry(
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
programs = device.appliance.get_programs_available()
|
||||
if programs:
|
||||
for program in programs.copy():
|
||||
for program in programs:
|
||||
if program not in PROGRAMS_TRANSLATION_KEYS_MAP:
|
||||
programs.remove(program)
|
||||
if program not in programs_not_found:
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==v7.0.1"],
|
||||
"requirements": ["python-homewizard-energy==v7.0.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2025.1.0"]
|
||||
"requirements": ["aioautomower==2024.12.0"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/idasen_desk",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["idasen-ha==2.6.3"]
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: todo
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
|
||||
@@ -20,6 +20,8 @@ from . import InComfortConfigEntry
|
||||
from .coordinator import InComfortDataCoordinator
|
||||
from .entity import IncomfortBoilerEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class IncomfortBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
@@ -22,6 +22,8 @@ from .const import DOMAIN
|
||||
from .coordinator import InComfortDataCoordinator
|
||||
from .entity import IncomfortEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -22,6 +22,8 @@ from . import InComfortConfigEntry
|
||||
from .coordinator import InComfortDataCoordinator
|
||||
from .entity import IncomfortBoilerEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class IncomfortSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -20,6 +20,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HEATER_ATTRS = ["display_code", "display_text", "is_burning"]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -385,7 +385,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
|
||||
@callback
|
||||
def async_set_datetime(self, date=None, time=None, datetime=None, timestamp=None):
|
||||
"""Set a new date / time."""
|
||||
if timestamp is not None:
|
||||
if timestamp:
|
||||
datetime = dt_util.as_local(dt_util.utc_from_timestamp(timestamp))
|
||||
|
||||
if datetime:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ipma",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["geopy", "pyipma"],
|
||||
"requirements": ["pyipma==3.0.7"]
|
||||
"requirements": ["pyipma==3.0.8"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyiqvia"],
|
||||
"requirements": ["numpy==2.2.0", "pyiqvia==2022.04.0"]
|
||||
"requirements": ["numpy==2.2.1", "pyiqvia==2022.04.0"]
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ from .coordinator import (
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Button platform for IronOS integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from pynecil import CharSetting
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import IronOSConfigEntry
|
||||
from .coordinator import IronOSCoordinators
|
||||
from .entity import IronOSBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class IronOSButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes IronOS button entity."""
|
||||
|
||||
characteristic: CharSetting
|
||||
|
||||
|
||||
class IronOSButton(StrEnum):
|
||||
"""Button controls for IronOS device."""
|
||||
|
||||
SETTINGS_RESET = "settings_reset"
|
||||
SETTINGS_SAVE = "settings_save"
|
||||
|
||||
|
||||
BUTTON_DESCRIPTIONS: tuple[IronOSButtonEntityDescription, ...] = (
|
||||
IronOSButtonEntityDescription(
|
||||
key=IronOSButton.SETTINGS_RESET,
|
||||
translation_key=IronOSButton.SETTINGS_RESET,
|
||||
characteristic=CharSetting.SETTINGS_RESET,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSButtonEntityDescription(
|
||||
key=IronOSButton.SETTINGS_SAVE,
|
||||
translation_key=IronOSButton.SETTINGS_SAVE,
|
||||
characteristic=CharSetting.SETTINGS_SAVE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IronOSConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up button entities from a config entry."""
|
||||
coordinators = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
IronOSButtonEntity(coordinators, description)
|
||||
for description in BUTTON_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class IronOSButtonEntity(IronOSBaseEntity, ButtonEntity):
|
||||
"""Implementation of a IronOS button entity."""
|
||||
|
||||
entity_description: IronOSButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinators: IronOSCoordinators,
|
||||
entity_description: IronOSButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinators.live_data, entity_description)
|
||||
|
||||
self.settings = coordinators.settings
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
|
||||
await self.settings.write(self.entity_description.characteristic, True)
|
||||
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from pynecil import (
|
||||
CharSetting,
|
||||
CommunicationError,
|
||||
DeviceInfoResponse,
|
||||
IronOSUpdate,
|
||||
@@ -19,6 +21,7 @@ from pynecil import (
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -147,3 +150,21 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
|
||||
_LOGGER.debug("Failed to fetch settings", exc_info=e)
|
||||
|
||||
return self.data or SettingsDataResponse()
|
||||
|
||||
async def write(self, characteristic: CharSetting, value: bool) -> None:
|
||||
"""Write value to the settings characteristic."""
|
||||
|
||||
try:
|
||||
await self.device.write(characteristic, value)
|
||||
except CommunicationError as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="submit_setting_failed",
|
||||
) from e
|
||||
|
||||
# prevent switch bouncing while waiting for coordinator to finish refresh
|
||||
self.data.update(
|
||||
cast(SettingsDataResponse, {characteristic.name.lower(): value})
|
||||
)
|
||||
self.async_update_listeners()
|
||||
await self.async_request_refresh()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user