Compare commits

..

2 Commits

Author SHA1 Message Date
Erik 8419e6429a Fix 2025-02-19 19:24:24 +01:00
Erik bbe804cef3 Add WS command homeassistant/expose_entity/list_exposed 2025-02-19 19:24:23 +01:00
222 changed files with 1688 additions and 9250 deletions
-100
View File
@@ -1,100 +0,0 @@
# Instructions for GitHub Copilot
This repository holds the core of Home Assistant, a Python 3 based home
automation application.
- Python code must be compatible with Python 3.13
- Use the newest Python language features if possible:
- Pattern matching
- Type hints
- f-strings for string formatting over `%` or `.format()`
- Dataclasses
- Walrus operator
- Code quality tools:
- Formatting: Ruff
- Linting: PyLint and Ruff
- Type checking: MyPy
- Testing: pytest with plain functions and fixtures
- Inline code documentation:
- File headers should be short and concise:
```python
"""Integration for Peblar EV chargers."""
```
- Every method and function needs a docstring:
```python
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
"""Set up Peblar from a config entry."""
...
```
- All code and comments and other text are written in American English
- Follow existing code style patterns as much as possible
- Core locations:
- Shared constants: `homeassistant/const.py`, use them instead of hardcoding
strings or creating duplicate integration constants.
- Integration files:
- Constants: `homeassistant/components/{domain}/const.py`
- Models: `homeassistant/components/{domain}/models.py`
- Coordinator: `homeassistant/components/{domain}/coordinator.py`
- Config flow: `homeassistant/components/{domain}/config_flow.py`
- Platform code: `homeassistant/components/{domain}/{platform}.py`
- All external I/O operations must be async
- Async patterns:
- Avoid sleeping in loops
- Avoid awaiting in loops, gather instead
- No blocking calls
- Polling:
- Follow update coordinator pattern, when possible
- Polling interval may not be configurable by the user
- For local network polling, the minimum interval is 5 seconds
- For cloud polling, the minimum interval is 60 seconds
- Error handling:
- Use specific exceptions from `homeassistant.exceptions`
- Setup failures:
- Temporary: Raise `ConfigEntryNotReady`
- Permanent: Use `ConfigEntryError`
- Logging:
- Message format:
- No periods at end
- No integration names or domains (added automatically)
- No sensitive data (keys, tokens, passwords), even when those are incorrect.
- Be very restrictive on the use of logging info messages, use debug for
anything which is not targeting the user.
- Use lazy logging (no f-strings):
```python
_LOGGER.debug("This is a log message with %s", variable)
```
- Entities:
- Ensure unique IDs for state persistence:
- Unique IDs should not contain values that are subject to user or network change.
- An ID needs to be unique per platform, not per integration.
- The ID does not have to contain the integration domain or platform.
- Acceptable examples:
- Serial number of a device
- MAC address of a device formatted using `homeassistant.helpers.device_registry.format_mac`
Do not obtain the MAC address through arp cache of local network access,
only use the MAC address provided by discovery or the device itself.
- Unique identifier that is physically printed on the device or burned into an EEPROM
- Not acceptable examples:
- IP Address
- Device name
- Hostname
- URL
- Email address
- Username
- For entities that are setup by a config entry, the config entry ID
can be used as a last resort if no other Unique ID is available.
For example: `f"{entry.entry_id}-battery"`
- If the state value is unknown, use `None`
- Do not use the `unavailable` string as a state value,
implement the `available()` property method instead
- Do not use the `unknown` string as a state value, use `None` instead
- Extra entity state attributes:
- The keys of all state attributes should always be present
- If the value is unknown, use `None`
- Provide descriptive state attributes
- Testing:
- Test location: `tests/components/{domain}/`
- Use pytest fixtures from `tests.common`
- Mock external dependencies
- Use snapshots for complex data
- Follow existing test patterns
+3 -3
View File
@@ -324,7 +324,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Cosign
uses: sigstore/cosign-installer@v3.8.1
uses: sigstore/cosign-installer@v3.8.0
with:
cosign-release: "v2.2.3"
@@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -522,7 +522,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
+22 -22
View File
@@ -240,7 +240,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.1
uses: actions/cache@v4.2.0
with:
path: venv
key: >-
@@ -256,7 +256,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.1
uses: actions/cache@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -286,7 +286,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -295,7 +295,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -326,7 +326,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -335,7 +335,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -366,7 +366,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -375,7 +375,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -482,7 +482,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.1
uses: actions/cache@v4.2.0
with:
path: venv
key: >-
@@ -490,7 +490,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@v4.2.1
uses: actions/cache@v4.2.0
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -578,7 +578,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -611,7 +611,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -649,7 +649,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -692,7 +692,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -739,7 +739,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -791,7 +791,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -799,7 +799,7 @@ jobs:
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@v4.2.1
uses: actions/cache@v4.2.0
with:
path: .mypy_cache
key: >-
@@ -865,7 +865,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -929,7 +929,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -1051,7 +1051,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -1181,7 +1181,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
@@ -1328,7 +1328,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.1
uses: actions/cache/restore@v4.2.0
with:
path: venv
fail-on-cache-miss: true
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.7
rev: v0.9.1
hooks:
- id: ruff
args:
-1
View File
@@ -438,7 +438,6 @@ homeassistant.components.select.*
homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.*
homeassistant.components.sensor.*
homeassistant.components.sensorpush_cloud.*
homeassistant.components.sensoterra.*
homeassistant.components.senz.*
homeassistant.components.sfr_box.*
+1 -9
View File
@@ -42,14 +42,6 @@
"--picked"
],
},
{
"name": "Home Assistant: Debug Current Test File",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"console": "integratedTerminal",
"args": ["-vv", "${file}"]
},
{
// Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/
@@ -85,4 +77,4 @@
]
}
]
}
}
Generated
-2
View File
@@ -1342,8 +1342,6 @@ build.json @home-assistant/supervisor
/tests/components/sensorpro/ @bdraco
/homeassistant/components/sensorpush/ @bdraco
/tests/components/sensorpush/ @bdraco
/homeassistant/components/sensorpush_cloud/ @sstallion
/tests/components/sensorpush_cloud/ @sstallion
/homeassistant/components/sensoterra/ @markruys
/tests/components/sensoterra/ @markruys
/homeassistant/components/sentry/ @dcramer @frenck
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "sensorpush",
"name": "SensorPush",
"integrations": ["sensorpush", "sensorpush_cloud"]
}
@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -28,13 +28,11 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
return True
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"iot_class": "local_polling",
"loggers": ["arcam"],
"requirements": ["arcam-fmj==1.8.1"],
"requirements": ["arcam-fmj==1.8.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
@@ -13,7 +13,7 @@ from pathlib import Path
from queue import Empty, Queue
from threading import Thread
import time
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import Any, Literal, cast
import wave
import hass_nabucasa
@@ -30,7 +30,7 @@ from homeassistant.components import (
from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, intent
@@ -81,9 +81,6 @@ from .error import (
)
from .vad import AudioBuffer, VoiceActivityTimeout, VoiceCommandSegmenter, chunk_samples
if TYPE_CHECKING:
from hassil.recognize import RecognizeResult
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY = f"{DOMAIN}.pipelines"
@@ -126,12 +123,6 @@ STORED_PIPELINE_RUNS = 10
SAVE_DELAY = 10
@callback
def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool:
"""Filter out intents that are not local fallback."""
return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND)
@callback
def _async_resolve_default_pipeline_settings(
hass: HomeAssistant,
@@ -1093,22 +1084,10 @@ class PipelineRun:
)
intent_response.async_set_speech(trigger_response_text)
intent_filter: Callable[[RecognizeResult], bool] | None = None
# If the LLM has API access, we filter out some sentences that are
# interfering with LLM operation.
if (
intent_agent_state := self.hass.states.get(self.intent_agent)
) and intent_agent_state.attributes.get(
ATTR_SUPPORTED_FEATURES, 0
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
# Try local intents first, if preferred.
elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
self.hass, user_input
)
):
# Local intent matched
+1 -2
View File
@@ -16,7 +16,7 @@ from .agent import (
BackupAgentPlatformProtocol,
LocalBackupAgent,
)
from .config import BackupConfig, CreateBackupParametersDict
from .config import BackupConfig
from .const import DATA_MANAGER, DOMAIN
from .http import async_register_http_views
from .manager import (
@@ -55,7 +55,6 @@ __all__ = [
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
"CreateBackupParametersDict",
"CreateBackupStage",
"CreateBackupState",
"Folder",
+1 -2
View File
@@ -154,8 +154,7 @@ class BackupConfig:
self.data.retention.apply(self._manager)
self.data.schedule.apply(self._manager)
@callback
def update(
async def update(
self,
*,
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
+1 -1
View File
@@ -1870,7 +1870,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
and "hassio.local" in create_backup.agent_ids
):
automatic_agents = [self._local_agent_id, *automatic_agents]
config.update(
await config.update(
create_backup=CreateBackupParametersDict(
agent_ids=automatic_agents,
include_addons=None,
+2 -5
View File
@@ -104,15 +104,12 @@ def read_backup(backup_path: Path) -> AgentBackup:
bool, homeassistant.get("exclude_database", False)
)
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
return AgentBackup(
addons=addons,
backup_id=cast(str, data["slug"]),
database_included=database_included,
date=cast(str, date),
extra_metadata=extra_metadata,
date=cast(str, data["date"]),
extra_metadata=cast(dict[str, bool | str], data.get("extra", {})),
folders=folders,
homeassistant_included=homeassistant_included,
homeassistant_version=homeassistant_version,
+3 -3
View File
@@ -346,7 +346,6 @@ async def handle_config_info(
)
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -388,7 +387,8 @@ async def handle_config_info(
),
}
)
def handle_config_update(
@websocket_api.async_response
async def handle_config_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
@@ -398,7 +398,7 @@ def handle_config_update(
changes = dict(msg)
changes.pop("id")
changes.pop("type")
manager.config.update(**changes)
await manager.config.update(**changes)
connection.send_result(msg["id"])
@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.22.3",
"bleak-retry-connector==3.9.0",
"bleak-retry-connector==3.8.1",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.4",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.22.1"
"habluetooth==3.22.0"
]
}
+7 -7
View File
@@ -31,7 +31,7 @@
"services": {
"set_fan_speed_tracked_state": {
"name": "Set fan speed tracked state",
"description": "Sets the tracked fan speed for a Bond fan.",
"description": "Sets the tracked fan speed for a bond fan.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -45,7 +45,7 @@
},
"set_switch_power_tracked_state": {
"name": "Set switch power tracked state",
"description": "Sets the tracked power state of a Bond switch.",
"description": "Sets the tracked power state of a bond switch.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -59,7 +59,7 @@
},
"set_light_power_tracked_state": {
"name": "Set light power tracked state",
"description": "Sets the tracked power state of a Bond light.",
"description": "Sets the tracked power state of a bond light.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -73,7 +73,7 @@
},
"set_light_brightness_tracked_state": {
"name": "Set light brightness tracked state",
"description": "Sets the tracked brightness state of a Bond light.",
"description": "Sets the tracked brightness state of a bond light.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -87,15 +87,15 @@
},
"start_increasing_brightness": {
"name": "Start increasing brightness",
"description": "Starts increasing the brightness of the light (deprecated)."
"description": "Start increasing the brightness of the light. (deprecated)."
},
"start_decreasing_brightness": {
"name": "Start decreasing brightness",
"description": "Starts decreasing the brightness of the light (deprecated)."
"description": "Start decreasing the brightness of the light. (deprecated)."
},
"stop": {
"name": "[%key:common::action::stop%]",
"description": "Stops any in-progress action and empty the queue (deprecated)."
"description": "Stop any in-progress action and empty the queue. (deprecated)."
}
}
}
@@ -2,12 +2,10 @@
from __future__ import annotations
from collections.abc import Callable
import logging
import re
from typing import Literal
from hassil.recognize import RecognizeResult
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -243,10 +241,7 @@ async def async_handle_sentence_triggers(
async def async_handle_intents(
hass: HomeAssistant,
user_input: ConversationInput,
*,
intent_filter: Callable[[RecognizeResult], bool] | None = None,
hass: HomeAssistant, user_input: ConversationInput
) -> intent.IntentResponse | None:
"""Try to match input against registered intents and return response.
@@ -255,9 +250,7 @@ async def async_handle_intents(
default_agent = async_get_agent(hass)
assert isinstance(default_agent, DefaultAgent)
return await default_agent.async_handle_intents(
user_input, intent_filter=intent_filter
)
return await default_agent.async_handle_intents(user_input)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -185,6 +185,21 @@ class IntentCache:
self.cache.clear()
def _get_language_variations(language: str) -> Iterable[str]:
"""Generate language codes with and without region."""
yield language
parts = re.split(r"([-_])", language)
if len(parts) == 3:
lang, sep, region = parts
if sep == "_":
# en_US -> en-US
yield f"{lang}-{region}"
# en-US -> en
yield lang
async def async_setup_default_agent(
hass: core.HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
@@ -1309,8 +1324,6 @@ class DefaultAgent(ConversationEntity):
async def async_handle_intents(
self,
user_input: ConversationInput,
*,
intent_filter: Callable[[RecognizeResult], bool] | None = None,
) -> intent.IntentResponse | None:
"""Try to match sentence against registered intents and return response.
@@ -1318,9 +1331,7 @@ class DefaultAgent(ConversationEntity):
Returns None if no match or a matching error occurred.
"""
result = await self.async_recognize_intent(user_input, strict_intents_only=True)
if not isinstance(result, RecognizeResult) or (
intent_filter is not None and intent_filter(result)
):
if not isinstance(result, RecognizeResult):
# No error message on failed match
return None
@@ -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==12.2.0"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"]
}
@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -28,13 +28,11 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
return True
@@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
from .entity import EnvoyBaseEntity, exception_handler
from .entity import EnvoyBaseEntity
PARALLEL_UPDATES = 1
@@ -132,7 +132,6 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity):
self.data.dry_contact_settings[self._relay_id]
)
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Update the relay."""
await self.envoy.update_dry_contact(
@@ -186,7 +185,6 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Update the storage setting."""
await self.entity_description.update_fn(self.envoy, value)
@@ -5,4 +5,3 @@ ATTR_STATION = "station"
CONF_STATION = "station"
CONF_TITLE = "title"
DOMAIN = "environment_canada"
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
@@ -21,9 +21,6 @@
"services": {
"set_radar_type": {
"service": "mdi:radar"
},
"get_forecasts": {
"service": "mdi:weather-cloudy-clock"
}
}
}
@@ -1,9 +1,3 @@
get_forecasts:
target:
entity:
integration: environment_canada
domain: weather
set_radar_type:
target:
entity:
@@ -113,10 +113,6 @@
}
},
"services": {
"get_forecasts": {
"name": "Get forecasts",
"description": "Retrieves the forecast from selected weather services."
},
"set_radar_type": {
"name": "Set radar type",
"description": "Sets the type of radar image to retrieve.",
@@ -35,16 +35,11 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import (
HomeAssistant,
ServiceResponse,
SupportsResponse,
callback,
)
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SERVICE_ENVIRONMENT_CANADA_FORECASTS
from .const import DOMAIN
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/
@@ -83,14 +78,6 @@ async def async_setup_entry(
async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)])
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_ENVIRONMENT_CANADA_FORECASTS,
None,
"_async_environment_canada_forecasts",
supports_response=SupportsResponse.ONLY,
)
def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str:
"""Calculate unique ID."""
@@ -198,23 +185,6 @@ class ECWeatherEntity(
"""Return the hourly forecast in native units."""
return get_forecast(self.ec_data, True)
def _async_environment_canada_forecasts(self) -> ServiceResponse:
"""Return the native Environment Canada forecast."""
daily = []
for f in self.ec_data.daily_forecasts:
day = f.copy()
day["timestamp"] = day["timestamp"].isoformat()
daily.append(day)
hourly = []
for f in self.ec_data.hourly_forecasts:
hour = f.copy()
hour["timestamp"] = hour["period"].isoformat()
del hour["period"]
hourly.append(hour)
return {"daily_forecast": daily, "hourly_forecast": hourly}
def get_forecast(ec_data, hourly) -> list[Forecast] | None:
"""Build the forecast array."""
@@ -6,7 +6,7 @@ import asyncio
import logging
from typing import Any
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -108,7 +108,8 @@ class ESPHomeDashboardManager:
reloads = [
hass.config_entries.async_reload(entry.entry_id)
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.state is ConfigEntryState.LOADED
]
# Re-auth flows will check the dashboard for encryption key when the form is requested
# but we only trigger reauth if the dashboard is available.
@@ -111,13 +111,7 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
else:
await self.device.set_air_temp_setpoint_home(temperature)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature",
translation_placeholders={
"temperature": str(temperature),
},
) from exc
raise HomeAssistantError from exc
finally:
await self.coordinator.async_refresh()
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/flexit_bacnet",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["flexit_bacnet==2.2.3"]
}
@@ -52,7 +52,7 @@ rules:
status: exempt
comment: |
Integration doesn't require any form of authentication.
test-coverage: done
test-coverage: todo
# Gold
entity-translations: done
entity-device-class: done
@@ -130,9 +130,6 @@
"set_preset_mode": {
"message": "Failed to set preset mode {preset}."
},
"set_temperature": {
"message": "Failed to set temperature {temperature}."
},
"set_hvac_mode": {
"message": "Failed to set HVAC mode {mode}."
},
+16 -10
View File
@@ -85,8 +85,6 @@ async def async_setup_entry(
class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
"""The thermostat class for FRITZ!SmartHome thermostats."""
_attr_max_temp = MAX_TEMPERATURE
_attr_min_temp = MIN_TEMPERATURE
_attr_precision = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "thermostat"
@@ -137,13 +135,11 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF:
target_temp = kwargs.get(ATTR_TEMPERATURE)
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode == HVACMode.OFF:
await self.async_set_hvac_mode(hvac_mode)
elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
if target_temp == OFF_API_TEMPERATURE:
target_temp = OFF_REPORT_SET_TEMPERATURE
elif target_temp == ON_API_TEMPERATURE:
target_temp = ON_REPORT_SET_TEMPERATURE
elif target_temp is not None:
await self.hass.async_add_executor_job(
self.data.set_target_temperature, target_temp, True
)
@@ -173,12 +169,12 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
translation_domain=DOMAIN,
translation_key="change_hvac_while_active_mode",
)
if self.hvac_mode is hvac_mode:
if self.hvac_mode == hvac_mode:
LOGGER.debug(
"%s is already in requested hvac mode %s", self.name, hvac_mode
)
return
if hvac_mode is HVACMode.OFF:
if hvac_mode == HVACMode.OFF:
await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE)
else:
if value_scheduled_preset(self.data) == PRESET_ECO:
@@ -212,6 +208,16 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
elif preset_mode == PRESET_ECO:
await self.async_set_temperature(temperature=self.data.eco_temperature)
@property
def min_temp(self) -> int:
"""Return the minimum temperature."""
return MIN_TEMPERATURE
@property
def max_temp(self) -> int:
"""Return the maximum temperature."""
return MAX_TEMPERATURE
@property
def extra_state_attributes(self) -> ClimateExtraAttributes:
"""Return the device specific state attributes."""
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
"requirements": ["pyfritzhome==0.6.15"],
"requirements": ["pyfritzhome==0.6.14"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"
+1 -1
View File
@@ -341,7 +341,7 @@ def get_next_departure(
{tomorrow_order}
origin_stop_time.departure_time
LIMIT :limit
""" # noqa: S608
"""
result = schedule.engine.connect().execute(
text(sql_query),
{
+5 -6
View File
@@ -119,13 +119,12 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
assert self.todo_items
if previous_uid:
pos = self.todo_items.index(
next(item for item in self.todo_items if item.uid == previous_uid)
pos = (
self.todo_items.index(
next(item for item in self.todo_items if item.uid == previous_uid)
)
+ 1
)
if pos < self.todo_items.index(
next(item for item in self.todo_items if item.uid == uid)
):
pos += 1
else:
pos = 0
+1 -20
View File
@@ -33,7 +33,6 @@ from homeassistant.components.backup import (
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
CreateBackupParametersDict,
CreateBackupStage,
CreateBackupState,
Folder,
@@ -636,25 +635,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
unsub()
async def async_validate_config(self, *, config: BackupConfig) -> None:
"""Validate backup config.
Replace the core backup agent with the hassio default agent.
"""
core_agent_id = "backup.local"
create_backup = config.data.create_backup
if core_agent_id not in create_backup.agent_ids:
_LOGGER.debug("Backup settings don't need to be adjusted")
return
default_agent = await _default_agent(self._client)
_LOGGER.info("Adjusting backup settings to not include core backup location")
automatic_agents = [
agent_id if agent_id != core_agent_id else default_agent
for agent_id in create_backup.agent_ids
]
config.update(
create_backup=CreateBackupParametersDict(agent_ids=automatic_agents)
)
"""Validate backup config."""
@callback
def _async_listen_job_events(
+40 -101
View File
@@ -5,22 +5,24 @@ import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from pyheos import (
CommandAuthenticationError,
ConnectionState,
Heos,
HeosError,
HeosOptions,
)
from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import (
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_FRIENDLY_NAME,
SsdpServiceInfo,
)
from .const import DOMAIN, ENTRY_TITLE
from .const import DOMAIN
from .coordinator import HeosConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -35,6 +37,11 @@ AUTH_SCHEMA = vol.Schema(
)
def format_title(host: str) -> str:
"""Format the title for config entries."""
return f"HEOS System (via {host})"
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(HeosOptions(host, events=False, heart_beat=False))
@@ -49,19 +56,13 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool:
async def _validate_auth(
user_input: dict[str, str], entry: HeosConfigEntry, errors: dict[str, str]
user_input: dict[str, str], heos: Heos, errors: dict[str, str]
) -> bool:
"""Validate authentication by signing in or out, otherwise populate errors if needed."""
can_validate = (
hasattr(entry, "runtime_data")
and entry.runtime_data.heos.connection_state is ConnectionState.CONNECTED
)
if not user_input:
# Log out (neither username nor password provided)
if not can_validate:
return True
try:
await entry.runtime_data.heos.sign_out()
await heos.sign_out()
except HeosError:
errors["base"] = "unknown"
_LOGGER.exception("Unexpected error occurred during sign-out")
@@ -80,12 +81,8 @@ async def _validate_auth(
return False
# Attempt to login (both username and password provided)
if not can_validate:
return True
try:
await entry.runtime_data.heos.sign_in(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
except CommandAuthenticationError as err:
errors["base"] = "invalid_auth"
_LOGGER.warning("Failed to sign-in to HEOS Account: %s", err)
@@ -97,32 +94,16 @@ async def _validate_auth(
else:
_LOGGER.debug(
"Successfully signed-in to HEOS Account: %s",
entry.runtime_data.heos.signed_in_username,
heos.signed_in_username,
)
return True
def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
"""Get a set of current hosts from the entry."""
hosts = set(entry.data[CONF_HOST])
if hasattr(entry, "runtime_data"):
hosts.update(
player.ip_address
for player in entry.runtime_data.heos.players.values()
if player.ip_address is not None
)
return hosts
class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
"""Define a flow for HEOS."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the HEOS flow."""
self._discovered_host: str | None = None
@staticmethod
@callback
def async_get_options_flow(config_entry: HeosConfigEntry) -> OptionsFlow:
@@ -136,84 +117,40 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
# Store discovered host
if TYPE_CHECKING:
assert discovery_info.ssdp_location
entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN)
hostname = urlparse(discovery_info.ssdp_location).hostname
assert hostname is not None
# Abort early when discovered host is part of the current system
if entry and hostname in _get_current_hosts(entry):
return self.async_abort(reason="single_instance_allowed")
# Connect to discovered host and get system information
heos = Heos(HeosOptions(hostname, events=False, heart_beat=False))
try:
await heos.connect()
system_info = await heos.get_system_info()
except HeosError as error:
_LOGGER.debug(
"Failed to retrieve system information from discovered HEOS device %s",
hostname,
exc_info=error,
)
return self.async_abort(reason="cannot_connect")
finally:
await heos.disconnect()
# Select the preferred host, if available
if system_info.preferred_hosts:
hostname = system_info.preferred_hosts[0].ip_address
# Move to confirmation when not configured
if entry is None:
self._discovered_host = hostname
return await self.async_step_confirm_discovery()
# Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload
if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]:
_LOGGER.debug(
"Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname
)
return self.async_update_reload_and_abort(
entry,
data_updates={CONF_HOST: hostname},
reason="reconfigure_successful",
)
return self.async_abort(reason="single_instance_allowed")
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovered HEOS system."""
if user_input is not None:
assert self._discovered_host is not None
return self.async_create_entry(
title=ENTRY_TITLE, data={CONF_HOST: self._discovered_host}
)
self._set_confirm_only()
return self.async_show_form(step_id="confirm_discovery")
friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})"
self.hass.data.setdefault(DOMAIN, {})
self.hass.data[DOMAIN][friendly_name] = hostname
await self.async_set_unique_id(DOMAIN)
# Show selection form
return self.async_show_form(step_id="user")
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Obtain host and validate connection."""
self.hass.data.setdefault(DOMAIN, {})
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured(error="single_instance_allowed")
# Try connecting to host if provided
errors: dict[str, str] = {}
host = None
if user_input is not None:
host = user_input[CONF_HOST]
# Map host from friendly name if in discovered hosts
host = self.hass.data[DOMAIN].get(host, host)
if await _validate_host(host, errors):
self.hass.data.pop(DOMAIN) # Remove discovery data
return self.async_create_entry(
title=ENTRY_TITLE, data={CONF_HOST: host}
title=format_title(host), data={CONF_HOST: host}
)
# Return form
host_type = (
str if not self.hass.data[DOMAIN] else vol.In(list(self.hass.data[DOMAIN]))
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}),
errors=errors,
)
@@ -249,7 +186,8 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
entry: HeosConfigEntry = self._get_reauth_entry()
if user_input is not None:
if await _validate_auth(user_input, entry, errors):
assert entry.state is ConfigEntryState.LOADED
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
return self.async_update_reload_and_abort(entry, options=user_input)
return self.async_show_form(
@@ -270,7 +208,8 @@ class HeosOptionsFlowHandler(OptionsFlow):
"""Manage the options."""
errors: dict[str, str] = {}
if user_input is not None:
if await _validate_auth(user_input, self.config_entry, errors):
entry: HeosConfigEntry = self.config_entry
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
return self.async_create_entry(data=user_input)
return self.async_show_form(
-1
View File
@@ -3,7 +3,6 @@
ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
DOMAIN = "heos"
ENTRY_TITLE = "HEOS System"
SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_GROUP_VOLUME_DOWN = "group_volume_down"
SERVICE_GROUP_VOLUME_UP = "group_volume_up"
@@ -9,6 +9,7 @@
"loggers": ["pyheos"],
"quality_scale": "silver",
"requirements": ["pyheos==1.0.2"],
"single_config_entry": true,
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
@@ -38,7 +38,9 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery-update-info:
status: todo
comment: Explore if this is possible.
discovery: done
docs-data-update: done
docs-examples: done
@@ -11,10 +11,6 @@
"host": "Host name or IP address of a HEOS-capable product (preferably one connected via wire to the network)."
}
},
"confirm_discovery": {
"title": "Discovered HEOS System",
"description": "Do you want to add your HEOS devices to Home Assistant?"
},
"reconfigure": {
"title": "Reconfigure HEOS",
"description": "Change the host name or IP address of the HEOS-capable product used to access your HEOS System.",
@@ -47,7 +43,6 @@
},
"abort": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
@@ -124,6 +124,9 @@ class ExposedEntities:
websocket_api.async_register_command(self._hass, ws_expose_new_entities_get)
websocket_api.async_register_command(self._hass, ws_expose_new_entities_set)
websocket_api.async_register_command(self._hass, ws_list_exposed_entities)
websocket_api.async_register_command(
self._hass, ws_list_entities_exposed_to_assistant
)
await self._async_load_data()
@callback
@@ -452,6 +455,30 @@ def ws_list_exposed_entities(
connection.send_result(msg["id"], {"exposed_entities": result})
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "homeassistant/expose_entity/list_exposed",
vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS),
}
)
def ws_list_entities_exposed_to_assistant(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""List entities which are exposed to an assistant."""
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
assistant = msg.get("assistant")
entity_registry = er.async_get(hass)
result = [
entity_id
for entity_id in chain(exposed_entities.entities, entity_registry.entities)
if assistant in (entity_settings := async_get_entity_settings(hass, entity_id))
and entity_settings[assistant].get("should_expose")
]
connection.send_result(msg["id"], {"exposed_entities": result})
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
@@ -28,13 +28,12 @@ from . import silabs_multiprotocol_addon
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
OwningAddon,
OwningIntegration,
get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
guess_hardware_owners,
probe_silabs_firmware_info,
probe_silabs_firmware_type,
)
_LOGGER = logging.getLogger(__name__)
@@ -53,7 +52,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Instantiate base flow."""
super().__init__(*args, **kwargs)
self._probed_firmware_info: FirmwareInfo | None = None
self._probed_firmware_type: ApplicationType | None = None
self._device: str | None = None # To be set in a subclass
self._hardware_name: str = "unknown" # To be set in a subclass
@@ -65,8 +64,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Shared translation placeholders."""
placeholders = {
"firmware_type": (
self._probed_firmware_info.firmware_type.value
if self._probed_firmware_info is not None
self._probed_firmware_type.value
if self._probed_firmware_type is not None
else "unknown"
),
"model": self._hardware_name,
@@ -121,49 +120,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(),
)
async def _probe_firmware_info(
self,
probe_methods: tuple[ApplicationType, ...] = (
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
),
) -> bool:
async def _probe_firmware_type(self) -> bool:
"""Probe the firmware currently on the device."""
assert self._device is not None
self._probed_firmware_info = await probe_silabs_firmware_info(
self._probed_firmware_type = await probe_silabs_firmware_type(
self._device,
probe_methods=probe_methods,
)
return (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type
in (
probe_methods=(
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
)
),
)
return self._probed_firmware_type in (
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
)
async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Zigbee firmware."""
if not await self._probe_firmware_info():
if not await self._probe_firmware_type():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
# Allow the stick to be used with ZHA without flashing
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
):
if self._probed_firmware_type == ApplicationType.EZSP:
return await self.async_step_confirm_zigbee()
if not is_hassio(self.hass):
@@ -349,12 +338,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
self._probed_firmware_type = ApplicationType.EZSP
if user_input is not None:
await self.hass.config_entries.flow.async_init(
@@ -382,7 +366,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
if not await self._probe_firmware_info():
if not await self._probe_firmware_type():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
@@ -474,11 +458,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm OTBR setup."""
assert self._device is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
self._probed_firmware_type = ApplicationType.SPINEL
if user_input is not None:
# OTBR discovery is done automatically via hassio
@@ -517,14 +497,14 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Zigbee and Thread options flow handlers."""
_probed_firmware_info: FirmwareInfo
def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None:
"""Instantiate options flow."""
super().__init__(*args, **kwargs)
self._config_entry = config_entry
self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"])
# Make `context` a regular dictionary
self.context = {}
@@ -5,5 +5,5 @@
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": ["universal-silabs-flasher==0.0.29"]
"requirements": ["universal-silabs-flasher==0.0.25"]
}
@@ -42,7 +42,6 @@ class ApplicationType(StrEnum):
CPC = "cpc"
EZSP = "ezsp"
SPINEL = "spinel"
ROUTER = "router"
@classmethod
def from_flasher_application_type(
@@ -249,10 +248,10 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
return guesses[-1][0]
async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device."""
) -> ApplicationType | None:
"""Probe the running firmware on a Silabs device."""
flasher = Flasher(
device=device,
**(
@@ -270,26 +269,4 @@ async def probe_silabs_firmware_info(
if flasher.app_type is None:
return None
return FirmwareInfo(
device=device,
firmware_type=ApplicationType.from_flasher_application_type(flasher.app_type),
firmware_version=(
flasher.app_version.orig_version
if flasher.app_version is not None
else None
),
source="probe",
owners=[],
)
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
if fw_info is None:
return None
return fw_info.firmware_type
return ApplicationType.from_flasher_application_type(flasher.app_type)
@@ -10,10 +10,7 @@ from homeassistant.components.homeassistant_hardware import (
firmware_config_flow,
silabs_multiprotocol_addon,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -121,7 +118,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Create the config entry."""
assert self._usb_info is not None
assert self._hw_variant is not None
assert self._probed_firmware_info is not None
assert self._probed_firmware_type is not None
return self.async_create_entry(
title=self._hw_variant.full_name,
@@ -133,7 +130,7 @@ class HomeAssistantSkyConnectConfigFlow(
"description": self._usb_info.description, # For backwards compatibility
"product": self._usb_info.description,
"device": self._usb_info.device,
"firmware": self._probed_firmware_info.firmware_type.value,
"firmware": self._probed_firmware_type.value,
},
)
@@ -206,26 +203,18 @@ class HomeAssistantSkyConnectOptionsFlowHandler(
self._hardware_name = self._hw_variant.full_name
self._device = self._usb_info.device
self._probed_firmware_info = FirmwareInfo(
device=self._device,
firmware_type=ApplicationType(self.config_entry.data["firmware"]),
firmware_version=None,
source="guess",
owners=[],
)
# Regenerate the translation placeholders
self._get_translation_placeholders()
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._probed_firmware_info is not None
assert self._probed_firmware_type is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
"firmware": self._probed_firmware_info.firmware_type.value,
"firmware": self._probed_firmware_type.value,
},
options=self.config_entry.options,
)
@@ -24,10 +24,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
SerialPortSettings as MultiprotocolSerialPortSettings,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
@@ -82,13 +79,10 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
await self._probe_firmware_info()
await self._probe_firmware_type()
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type is ApplicationType.EZSP
):
if self._probed_firmware_type is ApplicationType.EZSP:
discovery_flow.async_create_flow(
self.hass,
ZHA_DOMAIN,
@@ -104,11 +98,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
title=BOARD_NAME,
data={
# Assume the firmware type is EZSP if we cannot probe it
FIRMWARE: (
self._probed_firmware_info.firmware_type
if self._probed_firmware_info is not None
else ApplicationType.EZSP
).value,
FIRMWARE: (self._probed_firmware_type or ApplicationType.EZSP).value,
},
)
@@ -274,14 +264,6 @@ class HomeAssistantYellowOptionsFlowHandler(
self._hardware_name = BOARD_NAME
self._device = RADIO_DEVICE
self._probed_firmware_info = FirmwareInfo(
device=self._device,
firmware_type=ApplicationType(self.config_entry.data["firmware"]),
firmware_version=None,
source="guess",
owners=[],
)
# Regenerate the translation placeholders
self._get_translation_placeholders()
@@ -303,13 +285,13 @@ class HomeAssistantYellowOptionsFlowHandler(
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._probed_firmware_info is not None
assert self._probed_firmware_type is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
FIRMWARE: self._probed_firmware_info.firmware_type.value,
FIRMWARE: self._probed_firmware_type.value,
},
)
+1 -7
View File
@@ -14,13 +14,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BUTTON,
Platform.COVER,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
]
PLATFORMS = [Platform.COVER, Platform.SENSOR, Platform.SWITCH]
type HomeeConfigEntry = ConfigEntry[Homee]
-78
View File
@@ -1,78 +0,0 @@
"""The homee button platform."""
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = {
AttributeType.AUTOMATIC_MODE_IMPULSE: ButtonEntityDescription(key="automatic_mode"),
AttributeType.BRIEFLY_OPEN_IMPULSE: ButtonEntityDescription(key="briefly_open"),
AttributeType.IDENTIFICATION_MODE: ButtonEntityDescription(
key="identification_mode",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=ButtonDeviceClass.IDENTIFY,
),
AttributeType.IMPULSE: ButtonEntityDescription(key="impulse"),
AttributeType.LIGHT_IMPULSE: ButtonEntityDescription(key="light"),
AttributeType.OPEN_PARTIAL_IMPULSE: ButtonEntityDescription(key="open_partial"),
AttributeType.PERMANENTLY_OPEN_IMPULSE: ButtonEntityDescription(
key="permanently_open"
),
AttributeType.RESET_METER: ButtonEntityDescription(
key="reset_meter",
entity_category=EntityCategory.CONFIG,
),
AttributeType.VENTILATE_IMPULSE: ButtonEntityDescription(key="ventilate"),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the button component."""
async_add_entities(
HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
)
class HomeeButton(HomeeEntity, ButtonEntity):
"""Representation of a Homee button."""
def __init__(
self,
attribute: HomeeAttribute,
entry: HomeeConfigEntry,
description: ButtonEntityDescription,
) -> None:
"""Initialize a Homee button entity."""
super().__init__(attribute, entry)
self.entity_description = description
if attribute.instance == 0:
if attribute.type == AttributeType.IMPULSE:
self._attr_name = None
else:
self._attr_translation_key = description.key
else:
self._attr_translation_key = f"{description.key}_instance"
self._attr_translation_placeholders = {"instance": str(attribute.instance)}
async def async_press(self) -> None:
"""Handle the button press."""
await self.async_set_value(1)
-1
View File
@@ -76,7 +76,6 @@ CLIMATE_PROFILES = [
NodeProfile.WIFI_RADIATOR_THERMOSTAT,
NodeProfile.WIFI_ROOM_THERMOSTAT,
]
LIGHT_PROFILES = [
NodeProfile.DIMMABLE_COLOR_LIGHT,
NodeProfile.DIMMABLE_COLOR_METERING_PLUG,
-213
View File
@@ -1,213 +0,0 @@
"""The Homee light platform."""
from typing import Any
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import (
brightness_to_value,
color_hs_to_RGB,
color_RGB_to_hs,
value_to_brightness,
)
from . import HomeeConfigEntry
from .const import LIGHT_PROFILES
from .entity import HomeeNodeEntity
LIGHT_ATTRIBUTES = [
AttributeType.COLOR,
AttributeType.COLOR_MODE,
AttributeType.COLOR_TEMPERATURE,
AttributeType.DIMMING_LEVEL,
]
def is_light_node(node: HomeeNode) -> bool:
"""Determine if a node is controllable as a homee light based on its profile and attributes."""
assert node.attribute_map is not None
return node.profile in LIGHT_PROFILES and AttributeType.ON_OFF in node.attribute_map
def get_color_mode(supported_modes: set[ColorMode]) -> ColorMode:
"""Determine the color mode from the supported modes."""
if ColorMode.HS in supported_modes:
return ColorMode.HS
if ColorMode.COLOR_TEMP in supported_modes:
return ColorMode.COLOR_TEMP
if ColorMode.BRIGHTNESS in supported_modes:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
def get_light_attribute_sets(
node: HomeeNode,
) -> list[dict[AttributeType, HomeeAttribute]]:
"""Return the lights with their attributes as found in the node."""
lights: list[dict[AttributeType, HomeeAttribute]] = []
on_off_attributes = [
i for i in node.attributes if i.type == AttributeType.ON_OFF and i.editable
]
for a in on_off_attributes:
attribute_dict: dict[AttributeType, HomeeAttribute] = {a.type: a}
for attribute in node.attributes:
if attribute.instance == a.instance and attribute.type in LIGHT_ATTRIBUTES:
attribute_dict[attribute.type] = attribute
lights.append(attribute_dict)
return lights
def rgb_list_to_decimal(color: tuple[int, int, int]) -> int:
"""Convert an rgb color from list to decimal representation."""
return int(int(color[0]) << 16) + (int(color[1]) << 8) + (int(color[2]))
def decimal_to_rgb_list(color: float) -> list[int]:
"""Convert an rgb color from decimal to list representation."""
return [
(int(color) & 0xFF0000) >> 16,
(int(color) & 0x00FF00) >> 8,
(int(color) & 0x0000FF),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the light entity."""
async_add_entities(
HomeeLight(node, light, config_entry)
for node in config_entry.runtime_data.nodes
for light in get_light_attribute_sets(node)
if is_light_node(node)
)
class HomeeLight(HomeeNodeEntity, LightEntity):
"""Representation of a Homee light."""
def __init__(
self,
node: HomeeNode,
light: dict[AttributeType, HomeeAttribute],
entry: HomeeConfigEntry,
) -> None:
"""Initialize a Homee light."""
super().__init__(node, entry)
self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF]
self._dimmer_attr: HomeeAttribute | None = light.get(
AttributeType.DIMMING_LEVEL
)
self._col_attr: HomeeAttribute | None = light.get(AttributeType.COLOR)
self._temp_attr: HomeeAttribute | None = light.get(
AttributeType.COLOR_TEMPERATURE
)
self._mode_attr: HomeeAttribute | None = light.get(AttributeType.COLOR_MODE)
self._attr_supported_color_modes = self._get_supported_color_modes()
self._attr_color_mode = get_color_mode(self._attr_supported_color_modes)
if self._temp_attr is not None:
self._attr_min_color_temp_kelvin = int(self._temp_attr.minimum)
self._attr_max_color_temp_kelvin = int(self._temp_attr.maximum)
if self._on_off_attr.instance > 0:
self._attr_translation_key = "light_instance"
self._attr_translation_placeholders = {
"instance": str(self._on_off_attr.instance)
}
else:
# If a device has only one light, it will get its name.
self._attr_name = None
self._attr_unique_id = (
f"{entry.runtime_data.settings.uid}-{self._node.id}-{self._on_off_attr.id}"
)
@property
def brightness(self) -> int:
"""Return the brightness of the light."""
assert self._dimmer_attr is not None
return value_to_brightness(
(self._dimmer_attr.minimum + 1, self._dimmer_attr.maximum),
self._dimmer_attr.current_value,
)
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the color of the light."""
assert self._col_attr is not None
rgb = decimal_to_rgb_list(self._col_attr.current_value)
return color_RGB_to_hs(*rgb)
@property
def color_temp_kelvin(self) -> int:
"""Return the color temperature of the light."""
assert self._temp_attr is not None
return int(self._temp_attr.current_value)
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return bool(self._on_off_attr.current_value)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if ATTR_BRIGHTNESS in kwargs and self._dimmer_attr is not None:
target_value = round(
brightness_to_value(
(self._dimmer_attr.minimum, self._dimmer_attr.maximum),
kwargs[ATTR_BRIGHTNESS],
)
)
await self.async_set_value(self._dimmer_attr, target_value)
else:
# If no brightness value is given, just turn on.
await self.async_set_value(self._on_off_attr, 1)
if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None:
await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN])
if ATTR_HS_COLOR in kwargs:
color = kwargs[ATTR_HS_COLOR]
if self._col_attr is not None:
await self.async_set_value(
self._col_attr,
rgb_list_to_decimal(color_hs_to_RGB(*color)),
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
await self.async_set_value(self._on_off_attr, 0)
def _get_supported_color_modes(self) -> set[ColorMode]:
"""Determine the supported color modes from the available attributes."""
color_modes: set[ColorMode] = set()
if self._temp_attr is not None and self._temp_attr.editable:
color_modes.add(ColorMode.COLOR_TEMP)
if self._col_attr is not None:
color_modes.add(ColorMode.HS)
# If no other color modes are available, set one of those.
if len(color_modes) == 0:
if self._dimmer_attr is not None:
color_modes.add(ColorMode.BRIGHTNESS)
else:
color_modes.add(ColorMode.ONOFF)
return color_modes
+1 -1
View File
@@ -157,7 +157,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription(
key="rainfall_day",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
key="humidity",
@@ -26,46 +26,6 @@
}
},
"entity": {
"button": {
"automatic_mode": {
"name": "Automatic mode"
},
"briefly_open": {
"name": "Briefly open"
},
"identification_mode": {
"name": "Identification mode"
},
"impulse_instance": {
"name": "Impulse {instance}"
},
"light": {
"name": "Light"
},
"light_instance": {
"name": "Light {instance}"
},
"open_partial": {
"name": "Open partially"
},
"permanently_open": {
"name": "Open permanently"
},
"reset_meter": {
"name": "Reset meter"
},
"reset_meter_instance": {
"name": "Reset meter {instance}"
},
"ventilate": {
"name": "Ventilate"
}
},
"light": {
"light_instance": {
"name": "Light {instance}"
}
},
"sensor": {
"brightness_instance": {
"name": "Illuminance {instance}"
+16 -32
View File
@@ -4,20 +4,17 @@ from __future__ import annotations
import logging
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
from inkbird_ble import INKBIRDBluetoothDeviceData
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfo,
)
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_TYPE, DOMAIN
from .const import DOMAIN
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -28,33 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""
address = entry.unique_id
assert address is not None
device_type: str | None = entry.data.get(CONF_DEVICE_TYPE)
data = INKBIRDBluetoothDeviceData(device_type)
@callback
def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""
nonlocal device_type
update = data.update(service_info)
if device_type is None and data.device_type is not None:
device_type_str = str(data.device_type)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str}
)
device_type = device_type_str
return update
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=_async_on_update,
data = INKBIRDBluetoothDeviceData()
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
)
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# only start after all platforms have had a chance to subscribe
entry.async_on_unload(coordinator.async_start())
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe
return True
@@ -1,5 +1,3 @@
"""Constants for the INKBIRD Bluetooth integration."""
DOMAIN = "inkbird"
CONF_DEVICE_TYPE = "device_type"
@@ -28,5 +28,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"iot_class": "local_push",
"requirements": ["inkbird-ble==0.7.0"]
"requirements": ["inkbird-ble==0.5.8"]
}
@@ -20,7 +20,7 @@
"services": {
"select_next": {
"name": "Next",
"description": "Selects the next option.",
"description": "Select the next option.",
"fields": {
"cycle": {
"name": "Cycle",
@@ -17,7 +17,7 @@ from homeassistant.util.dt import parse_datetime
from .browse_media import build_item_response, build_root_response
from .client_wrapper import get_artwork_url
from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH
from .const import CONTENT_TYPE_MAP, LOGGER
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
from .entity import JellyfinClientEntity
@@ -169,9 +169,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
if self.now_playing is None:
return None
return get_artwork_url(
self.coordinator.api_client, self.now_playing, MAX_IMAGE_WIDTH
)
return get_artwork_url(self.coordinator.api_client, self.now_playing, 150)
@property
def supported_features(self) -> MediaPlayerEntityFeature:
+8 -11
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -26,14 +26,11 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
"""Unload config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
return True
@@ -1 +0,0 @@
"""LINAK virtual integration."""
@@ -1,6 +0,0 @@
{
"domain": "linak",
"name": "LINAK",
"integration_type": "virtual",
"supported_by": "idasen_desk"
}
@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -43,16 +43,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
@@ -277,21 +277,6 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
}
)
# See mappings at https://github.com/home-assistant/core/issues/137548#issuecomment-2643440119
PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = {
"on": 2, # 'Number': 2 in LIP
"off": 4, # 'Number': 4 in LIP
}
PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = {
"on": 0, # 'ButtonNumber': 0 in LEAP
"off": 2, # 'ButtonNumber': 2 in LEAP
}
PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP),
}
)
DEVICE_TYPE_SCHEMA_MAP = {
"Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA,
@@ -303,7 +288,6 @@ DEVICE_TYPE_SCHEMA_MAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
"FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
"PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
@@ -316,7 +300,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP,
"PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
@@ -329,7 +312,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP,
"PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP,
}
LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = {
@@ -344,7 +326,6 @@ TRIGGER_SCHEMA = vol.Any(
PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
)
+8 -10
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -29,13 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
return True
+6 -3
View File
@@ -25,6 +25,7 @@ from mcp import types
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
@@ -55,9 +56,11 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
Will raise an HTTP error if the expected configuration is not present.
"""
config_entries: list[MCPServerConfigEntry] = (
hass.config_entries.async_loaded_entries(DOMAIN)
)
config_entries: list[MCPServerConfigEntry] = [
config_entry
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.state == ConfigEntryState.LOADED
]
if not config_entries:
raise HTTPNotFound(text="Model Context Protocol server is not configured")
if len(config_entries) > 1:
@@ -299,22 +299,22 @@
"description": "Removes all items from the playlist."
},
"shuffle_set": {
"name": "Set shuffle",
"description": "Enables or disables the shuffle mode.",
"name": "Shuffle",
"description": "Playback mode that selects the media in randomized order.",
"fields": {
"shuffle": {
"name": "Shuffle mode",
"description": "Whether the media should be played in randomized order or not."
"name": "Shuffle",
"description": "Whether or not shuffle mode is enabled."
}
}
},
"repeat_set": {
"name": "Set repeat",
"description": "Sets the repeat mode.",
"name": "Repeat",
"description": "Playback mode that plays the media in a loop.",
"fields": {
"repeat": {
"name": "Repeat mode",
"description": "Whether the media (one or all) should be played in a loop or not."
"description": "Repeat mode to set."
}
}
},
+5 -5
View File
@@ -2,11 +2,11 @@
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
"description": "Reloads all Modbus entities."
"description": "Reloads all modbus entities."
},
"write_coil": {
"name": "Write coil",
"description": "Writes to a Modbus coil.",
"description": "Writes to a modbus coil.",
"fields": {
"address": {
"name": "Address",
@@ -17,8 +17,8 @@
"description": "State to write."
},
"slave": {
"name": "Server",
"description": "Address of the Modbus unit/server."
"name": "Slave",
"description": "Address of the modbus unit/slave."
},
"hub": {
"name": "Hub",
@@ -28,7 +28,7 @@
},
"write_register": {
"name": "Write register",
"description": "Writes to a Modbus holding register.",
"description": "Writes to a modbus holding register.",
"fields": {
"address": {
"name": "[%key:component::modbus::services::write_coil::fields::address::name%]",
+8 -10
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -29,13 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
return True
@@ -2,7 +2,7 @@
"services": {
"aux": {
"name": "Aux",
"description": "Changes the state of an aux output.",
"description": "Trigger an aux output.",
"fields": {
"output_id": {
"name": "Output ID",
@@ -10,17 +10,17 @@
},
"state": {
"name": "State",
"description": "The on/off state of the output. If P14xE 8E is enabled then turning on will pulse the output for the time specified in P14(x+4)E."
"description": "The On/Off State. If P14xE 8E is enabled then a value of true will pulse output x for the time specified in P14(x+4)E."
}
}
},
"panic": {
"name": "Panic",
"description": "Triggers a panic alarm.",
"description": "Triggers a panic.",
"fields": {
"code": {
"name": "Code",
"description": "The user code to use to trigger the panic alarm."
"description": "The user code to use to trigger the panic."
}
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ from collections.abc import Mapping
from google_nest_sdm.device import Device
from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -83,7 +84,8 @@ def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]:
"""Return a mapping of all nest devices for all config entries."""
return {
device.name: device
for config_entry in hass.config_entries.async_loaded_entries(DOMAIN)
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.state == ConfigEntryState.LOADED
for device in config_entry.runtime_data.device_manager.devices.values()
}
@@ -39,7 +39,7 @@ set_preset_mode_with_end_datetime:
select:
options:
- "away"
- "frost_guard"
- "Frost Guard"
end_datetime:
required: true
example: '"2019-04-20 05:04:20"'
@@ -99,11 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
_async_notify_backup_listeners_soon(hass)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
+3 -7
View File
@@ -31,7 +31,7 @@ from homeassistant.components.backup import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .coordinator import OneDriveConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -205,12 +205,8 @@ class OneDriveBackupAgent(BackupAgent):
backup = backups[backup_id]
delete_permanently = self._entry.options.get(CONF_DELETE_PERMANENTLY, False)
await self._client.delete_drive_item(backup.backup_file_id, delete_permanently)
await self._client.delete_drive_item(
backup.metadata_file_id, delete_permanently
)
await self._client.delete_drive_item(backup.backup_file_id)
await self._client.delete_drive_item(backup.metadata_file_id)
self._cache_expiration = time()
@handle_backup_errors
@@ -1,23 +1,18 @@
"""Config flow for OneDrive."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any, cast
from onedrive_personal_sdk.clients.client import OneDriveClient
from onedrive_personal_sdk.exceptions import OneDriveException
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES
from .coordinator import OneDriveConfigEntry
from .const import DOMAIN, OAUTH_SCOPES
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
@@ -91,38 +86,3 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
@staticmethod
@callback
def async_get_options_flow(
config_entry: OneDriveConfigEntry,
) -> OneDriveOptionsFlowHandler:
"""Create the options flow."""
return OneDriveOptionsFlowHandler()
class OneDriveOptionsFlowHandler(OptionsFlow):
"""Handles options flow for the component."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options for OneDrive."""
if user_input:
return self.async_create_entry(title="", data=user_input)
options_schema = vol.Schema(
{
vol.Optional(
CONF_DELETE_PERMANENTLY,
default=self.config_entry.options.get(
CONF_DELETE_PERMANENTLY, False
),
): bool,
}
)
return self.async_show_form(
step_id="init",
data_schema=options_schema,
)
@@ -7,8 +7,6 @@ from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "onedrive"
CONF_DELETE_PERMANENTLY: Final = "delete_permanently"
# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support
OAUTH2_AUTHORIZE: Final = (
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "bronze",
"requirements": ["onedrive-personal-sdk==0.0.11"]
"requirements": ["onedrive-personal-sdk==0.0.10"]
}
@@ -30,7 +30,10 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-configuration-parameters:
status: exempt
comment: |
No Options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
@@ -29,19 +29,6 @@
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"options": {
"step": {
"init": {
"description": "By default, files are put into the Recycle Bin when deleted, where they remain available for another 30 days. If you enable this option, files will be deleted immediately when they are cleaned up by the backup system.",
"data": {
"delete_permanently": "Delete files permanently"
},
"data_description": {
"delete_permanently": "Delete files without moving them to the Recycle Bin"
}
}
}
},
"issues": {
"drive_full": {
"title": "OneDrive data cap exceeded",
+1 -1
View File
@@ -235,7 +235,7 @@ class ONVIFDevice:
LOGGER.debug("%s: Retrieving current device date/time", self.name)
try:
device_time = await device_mgmt.GetSystemDateAndTime()
except (RequestError, Fault) as err:
except RequestError as err:
LOGGER.warning(
"Couldn't get device '%s' date/time. Error: %s", self.name, err
)
+6 -7
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from opower import Forecast, MeterType, UnitOfMeasure
@@ -29,7 +28,7 @@ from .coordinator import OpowerConfigEntry, OpowerCoordinator
class OpowerEntityDescription(SensorEntityDescription):
"""Class describing Opower sensors entities."""
value_fn: Callable[[Forecast], str | float | date]
value_fn: Callable[[Forecast], str | float]
# suggested_display_precision=0 for all sensors since
@@ -97,7 +96,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.start_date,
value_fn=lambda data: str(data.start_date),
),
OpowerEntityDescription(
key="elec_end_date",
@@ -105,7 +104,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.end_date,
value_fn=lambda data: str(data.end_date),
),
)
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
@@ -169,7 +168,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.start_date,
value_fn=lambda data: str(data.start_date),
),
OpowerEntityDescription(
key="gas_end_date",
@@ -177,7 +176,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.end_date,
value_fn=lambda data: str(data.end_date),
),
)
@@ -247,7 +246,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
self.utility_account_id = utility_account_id
@property
def native_value(self) -> StateType | date:
def native_value(self) -> StateType:
"""Return the state."""
if self.coordinator.data is not None:
return self.entity_description.value_fn(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/prosegur",
"iot_class": "cloud_polling",
"loggers": ["pyprosegur"],
"requirements": ["pyprosegur==0.0.13"]
"requirements": ["pyprosegur==0.0.9"]
}
@@ -4,6 +4,7 @@ from __future__ import annotations
from aiohttp import CookieJar
from pyloadapi.api import PyLoadAPI
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
from homeassistant.const import (
CONF_HOST,
@@ -15,8 +16,10 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DOMAIN
from .coordinator import PyLoadConfigEntry, PyLoadCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
@@ -42,6 +45,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo
password=entry.data[CONF_PASSWORD],
)
try:
await pyloadapi.login()
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e
except ParserError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_parse_exception",
) from e
except InvalidAuth as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]},
) from e
coordinator = PyLoadCoordinator(hass, entry, pyloadapi)
await coordinator.async_config_entry_first_refresh()
@@ -18,8 +18,6 @@ from .const import DOMAIN
from .coordinator import PyLoadConfigEntry
from .entity import BasePyLoadEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class PyLoadButtonEntityDescription(ButtonEntityDescription):
+9 -31
View File
@@ -9,7 +9,7 @@ from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -59,11 +59,14 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
async def _async_update_data(self) -> PyLoadData:
"""Fetch data from API endpoint."""
try:
if not self.version:
self.version = await self.pyload.version()
return PyLoadData(
**await self.pyload.get_status(),
free_space=await self.pyload.free_space(),
)
except InvalidAuth:
except InvalidAuth as e:
try:
await self.pyload.login()
except InvalidAuth as exc:
@@ -72,38 +75,13 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: self.pyload.username},
) from exc
_LOGGER.debug(
"Unable to retrieve data due to cookie expiration, retrying after 20 seconds"
)
return self.data
raise UpdateFailed(
"Unable to retrieve data due to cookie expiration"
) from e
except CannotConnect as e:
raise UpdateFailed(
"Unable to connect and retrieve data from pyLoad API"
) from e
except ParserError as e:
raise UpdateFailed("Unable to parse data from pyLoad API") from e
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.pyload.login()
self.version = await self.pyload.version()
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e
except ParserError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_parse_exception",
) from e
except InvalidAuth as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={
CONF_USERNAME: self.config_entry.data[CONF_USERNAME]
},
) from e
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pyloadapi"],
"requirements": ["PyLoadAPI==1.4.1"]
"requirements": ["PyLoadAPI==1.3.2"]
}
@@ -21,8 +21,6 @@ from .const import UNIT_DOWNLOADS
from .coordinator import PyLoadConfigEntry, PyLoadData
from .entity import BasePyLoadEntity
PARALLEL_UPDATES = 0
class PyLoadSensorEntity(StrEnum):
"""pyLoad Sensor Entities."""
@@ -22,8 +22,6 @@ from .const import DOMAIN
from .coordinator import PyLoadConfigEntry, PyLoadData
from .entity import BasePyLoadEntity
PARALLEL_UPDATES = 1
class PyLoadSwitch(StrEnum):
"""PyLoad Switch Entities."""
+8 -5
View File
@@ -71,14 +71,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> boo
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.shutdown()
_cleanup(hass, entry)
cleanup(hass, entry)
return unload_ok
def _cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None:
def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None:
"""Shutdown if no more entries are loaded."""
if not hass.config_entries.async_loaded_entries(DOMAIN) and (
config_coordinator := hass.data.get(QBUS_KEY)
):
entries = hass.config_entries.async_loaded_entries(DOMAIN)
count = len(entries)
# During unloading of the entry, it is not marked as unloaded yet. So
# count can be 1 if it is the last one.
if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)):
config_coordinator.shutdown()
+1 -4
View File
@@ -5,10 +5,7 @@ from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "qbus"
PLATFORMS: list[Platform] = [
Platform.LIGHT,
Platform.SWITCH,
]
PLATFORMS: list[Platform] = [Platform.SWITCH]
CONF_SERIAL_NUMBER: Final = "serial"
-27
View File
@@ -1,9 +1,6 @@
"""Base class for Qbus entities."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
import re
from qbusmqttapi.discovery import QbusMqttOutput
@@ -13,36 +10,12 @@ from qbusmqttapi.state import QbusMqttState
from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .coordinator import QbusControllerCoordinator
_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
def add_new_outputs(
coordinator: QbusControllerCoordinator,
added_outputs: list[QbusMqttOutput],
filter_fn: Callable[[QbusMqttOutput], bool],
entity_type: type[QbusEntity],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Call async_add_entities for new outputs."""
added_ref_ids = {k.ref_id for k in added_outputs}
new_outputs = [
output
for output in coordinator.data
if filter_fn(output) and output.ref_id not in added_ref_ids
]
if new_outputs:
added_outputs.extend(new_outputs)
async_add_entities([entity_type(output) for output in new_outputs])
def format_ref_id(ref_id: str) -> str | None:
"""Format the Qbus ref_id."""
matches: list[str] = re.findall(_REFID_REGEX, ref_id)
-110
View File
@@ -1,110 +0,0 @@
"""Support for Qbus light."""
from typing import Any
from qbusmqttapi.discovery import QbusMqttOutput
from qbusmqttapi.state import QbusMqttAnalogState, StateType
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
def _check_outputs() -> None:
add_new_outputs(
coordinator,
added_outputs,
lambda output: output.type == "analog",
QbusLight,
async_add_entities,
)
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
class QbusLight(QbusEntity, LightEntity):
"""Representation of a Qbus light entity."""
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize light entity."""
super().__init__(mqtt_output)
self._set_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
percentage: int | None = None
on: bool | None = None
state = QbusMqttAnalogState(id=self._mqtt_output.id)
if brightness is None:
on = True
state.type = StateType.ACTION
state.write_on_off(on)
else:
percentage = round(brightness_to_value((1, 100), brightness))
state.type = StateType.STATE
state.write_percentage(percentage)
await self._async_publish_output_state(state)
self._set_state(percentage=percentage, on=on)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
state = QbusMqttAnalogState(id=self._mqtt_output.id, type=StateType.ACTION)
state.write_on_off(on=False)
await self._async_publish_output_state(state)
self._set_state(on=False)
async def _state_received(self, msg: ReceiveMessage) -> None:
output = self._message_factory.parse_output_state(
QbusMqttAnalogState, msg.payload
)
if output is not None:
percentage = round(output.read_percentage())
self._set_state(percentage=percentage)
self.async_schedule_update_ha_state()
def _set_state(
self, *, percentage: int | None = None, on: bool | None = None
) -> None:
if percentage is None:
# When turning on without brightness, we don't know the desired
# brightness. It will be set during _state_received().
if on is True:
self._attr_is_on = True
else:
self._attr_is_on = False
self._attr_brightness = 0
else:
self._attr_is_on = percentage > 0
self._attr_brightness = value_to_brightness((1, 100), percentage)
+19 -11
View File
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
from .entity import QbusEntity, add_new_outputs
from .entity import QbusEntity
PARALLEL_UPDATES = 0
@@ -19,21 +19,26 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: QbusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities."""
coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
# Local function that calls add_entities for new entities
def _check_outputs() -> None:
add_new_outputs(
coordinator,
added_outputs,
lambda output: output.type == "onoff",
QbusSwitch,
async_add_entities,
)
added_output_ids = {k.id for k in added_outputs}
new_outputs = [
item
for item in coordinator.data
if item.type == "onoff" and item.id not in added_output_ids
]
if new_outputs:
added_outputs.extend(new_outputs)
add_entities([QbusSwitch(output) for output in new_outputs])
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
@@ -44,7 +49,10 @@ class QbusSwitch(QbusEntity, SwitchEntity):
_attr_device_class = SwitchDeviceClass.SWITCH
def __init__(self, mqtt_output: QbusMqttOutput) -> None:
def __init__(
self,
mqtt_output: QbusMqttOutput,
) -> None:
"""Initialize switch entity."""
super().__init__(mqtt_output)
+1 -2
View File
@@ -43,7 +43,6 @@ from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
)
from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
@@ -184,7 +183,7 @@ class Recorder(threading.Thread):
self.db_retry_wait = db_retry_wait
self.database_engine: DatabaseEngine | None = None
# Database connection is ready, but non-live migration may be in progress
db_connected: asyncio.Future[bool] = hass.data[DATA_RECORDER].db_connected
db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected
self.async_db_connected: asyncio.Future[bool] = db_connected
# Database is ready to use but live migration may be in progress
self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future()
@@ -24,7 +24,6 @@ import voluptuous as vol
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
@@ -562,9 +561,7 @@ def _compile_statistics(
platform_stats: list[StatisticResult] = []
current_metadata: dict[str, tuple[int, StatisticMetaData]] = {}
# Collect statistics from all platforms implementing support
for domain, platform in instance.hass.data[
DATA_RECORDER
].recorder_platforms.items():
for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items():
if not (
platform_compile_statistics := getattr(
platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None
@@ -602,7 +599,7 @@ def _compile_statistics(
if start.minute == 50:
# Once every hour, update issues
for platform in instance.hass.data[DATA_RECORDER].recorder_platforms.values():
for platform in instance.hass.data[DOMAIN].recorder_platforms.values():
if not (
platform_update_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
@@ -885,7 +882,7 @@ def list_statistic_ids(
# the integrations for the missing ones.
#
# Query all integrations with a registered recorder platform
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
for platform in hass.data[DOMAIN].recorder_platforms.values():
if not (
platform_list_statistic_ids := getattr(
platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None
@@ -2235,7 +2232,7 @@ def _sorted_statistics_to_dict(
def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]:
"""Validate statistics."""
platform_validation: dict[str, list[ValidationIssue]] = {}
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
for platform in hass.data[DOMAIN].recorder_platforms.values():
if platform_validate_statistics := getattr(
platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None
):
@@ -2246,7 +2243,7 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]
def update_statistics_issues(hass: HomeAssistant) -> None:
"""Update statistics issues."""
with session_scope(hass=hass, read_only=True) as session:
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
for platform in hass.data[DOMAIN].recorder_platforms.values():
if platform_update_statistics_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
):
+2 -2
View File
@@ -11,11 +11,11 @@ import logging
import threading
from typing import TYPE_CHECKING, Any
from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.typing import UndefinedType
from homeassistant.util.event_type import EventType
from . import entity_registry, purge, statistics
from .const import DOMAIN
from .db_schema import Statistics, StatisticsShortTerm
from .models import StatisticData, StatisticMetaData
from .util import periodic_db_cleanups, session_scope
@@ -308,7 +308,7 @@ class AddRecorderPlatformTask(RecorderTask):
hass = instance.hass
domain = self.domain
platform = self.platform
platforms: dict[str, Any] = hass.data[DATA_RECORDER].recorder_platforms
platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms
platforms[domain] = platform
@@ -1,24 +1,33 @@
"""Support to interact with Remember The Milk."""
from aiortm import AioRTMClient, Auth, AuthError
import json
import logging
from pathlib import Path
from rtmapi import Rtm
import voluptuous as vol
from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER
from .entity import RememberTheMilkEntity
from .storage import RememberTheMilkConfiguration
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work.
_LOGGER = logging.getLogger(__name__)
DOMAIN = "remember_the_milk"
DEFAULT_NAME = DOMAIN
CONF_SHARED_SECRET = "shared_secret"
CONF_ID_MAP = "id_map"
CONF_LIST_ID = "list_id"
CONF_TIMESERIES_ID = "timeseries_id"
CONF_TASK_ID = "task_id"
RTM_SCHEMA = vol.Schema(
{
@@ -32,6 +41,7 @@ CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA
)
CONFIG_FILE_NAME = ".remember_the_milk.conf"
SERVICE_CREATE_TASK = "create_task"
SERVICE_COMPLETE_TASK = "complete_task"
@@ -42,21 +52,20 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Remember the milk component."""
component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass)
component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass)
stored_rtm_config = RememberTheMilkConfiguration(hass)
await stored_rtm_config.setup()
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
LOGGER.debug("Adding Remember the milk account %s", account_name)
_LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key = rtm_config[CONF_API_KEY]
shared_secret = rtm_config[CONF_SHARED_SECRET]
token = stored_rtm_config.get_token(account_name)
if token:
LOGGER.debug("found token for account %s", account_name)
await _create_instance(
_LOGGER.debug("found token for account %s", account_name)
_create_instance(
hass,
account_name,
api_key,
@@ -66,43 +75,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
component,
)
else:
await _register_new_account(
_register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
)
LOGGER.debug("Finished adding all Remember the milk accounts")
_LOGGER.debug("Finished adding all Remember the milk accounts")
return True
async def _create_instance(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
token: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
client = AioRTMClient(
Auth(
async_get_clientsession(hass),
api_key,
shared_secret,
token,
permission="delete",
)
def _create_instance(
hass, account_name, api_key, shared_secret, token, stored_rtm_config, component
):
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
)
entity = RememberTheMilkEntity(account_name, client, stored_rtm_config)
LOGGER.debug("Instance created for account %s", entity.name)
await entity.check_token()
await component.async_add_entities([entity])
hass.services.async_register(
component.add_entities([entity])
hass.services.register(
DOMAIN,
f"{account_name}_create_task",
entity.create_task,
schema=SERVICE_SCHEMA_CREATE_TASK,
)
hass.services.async_register(
hass.services.register(
DOMAIN,
f"{account_name}_complete_task",
entity.complete_task,
@@ -110,42 +104,29 @@ async def _create_instance(
)
async def _register_new_account(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
"""Register a new account."""
auth = Auth(
async_get_clientsession(hass), api_key, shared_secret, permission="write"
)
url, frob = await auth.authenticate_desktop()
LOGGER.debug("Sent authentication request to server")
def _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
):
request_id = None
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
_LOGGER.debug("Sent authentication request to server")
@callback
def register_account_callback(fields: list[dict[str, str]]) -> None:
"""Call for register the configurator."""
hass.async_create_task(handle_token(auth, frob))
async def handle_token(auth: Auth, frob: str) -> None:
"""Handle token."""
try:
auth_data = await auth.get_token(frob)
except AuthError:
LOGGER.error("Failed to register, please try again")
configurator.async_notify_errors(
api.retrieve_token(frob)
token = api.token
if api.token is None:
_LOGGER.error("Failed to register, please try again")
configurator.notify_errors(
hass, request_id, "Failed to register, please try again."
)
return
token: str = auth_data["token"]
stored_rtm_config.set_token(account_name, token)
LOGGER.debug("Retrieved new token from server")
_LOGGER.debug("Retrieved new token from server")
await _create_instance(
_create_instance(
hass,
account_name,
api_key,
@@ -155,9 +136,9 @@ async def _register_new_account(
component,
)
configurator.async_request_done(hass, request_id)
configurator.request_done(hass, request_id)
request_id = configurator.async_request_config(
request_id = configurator.request_config(
hass,
f"{DOMAIN} - {account_name}",
callback=register_account_callback,
@@ -171,3 +152,104 @@ async def _register_new_account(
link_url=url,
submit_caption="login completed",
)
class RememberTheMilkConfiguration:
"""Internal configuration data for RememberTheMilk class.
This class stores the authentication token it get from the backend.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
self._config = {}
_LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
try:
self._config = json.loads(
Path(self._config_file_path).read_text(encoding="utf8")
)
except FileNotFoundError:
_LOGGER.debug("Missing configuration file: %s", self._config_file_path)
except OSError:
_LOGGER.debug(
"Failed to read from configuration file, %s, using empty configuration",
self._config_file_path,
)
except ValueError:
_LOGGER.error(
"Failed to parse configuration file, %s, using empty configuration",
self._config_file_path,
)
def _save_config(self) -> None:
"""Write the configuration to a file."""
Path(self._config_file_path).write_text(
json.dumps(self._config), encoding="utf8"
)
def get_token(self, profile_name: str) -> str | None:
"""Get the server token for a profile."""
if profile_name in self._config:
return self._config[profile_name][CONF_TOKEN]
return None
def set_token(self, profile_name: str, token: str) -> None:
"""Store a new server token for a profile."""
self._initialize_profile(profile_name)
self._config[profile_name][CONF_TOKEN] = token
self._save_config()
def delete_token(self, profile_name: str) -> None:
"""Delete a token for a profile.
Usually called when the token has expired.
"""
self._config.pop(profile_name, None)
self._save_config()
def _initialize_profile(self, profile_name: str) -> None:
"""Initialize the data structures for a profile."""
if profile_name not in self._config:
self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {}
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
list id, timeseries id and the task id.
"""
self._initialize_profile(profile_name)
ids = self._config[profile_name][CONF_ID_MAP].get(hass_id)
if ids is None:
return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
def set_rtm_id(
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
self._initialize_profile(profile_name)
id_tuple = {
CONF_LIST_ID: list_id,
CONF_TIMESERIES_ID: time_series_id,
CONF_TASK_ID: rtm_task_id,
}
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
self._save_config()
def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
"""Delete a key mapping."""
self._initialize_profile(profile_name)
if hass_id in self._config[profile_name][CONF_ID_MAP]:
del self._config[profile_name][CONF_ID_MAP][hass_id]
self._save_config()
@@ -1,6 +0,0 @@
"""Constants for the Remember The Milk integration."""
import logging
DOMAIN = "remember_the_milk"
LOGGER = logging.getLogger(__package__)
@@ -1,64 +1,50 @@
"""Support to interact with Remember The Milk."""
from __future__ import annotations
import logging
from aiortm import AioRTMClient, AioRTMError, AuthError
from rtmapi import Rtm, RtmRequestFailedException
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK
from homeassistant.core import ServiceCall
from homeassistant.helpers.entity import Entity
from .const import LOGGER
from .storage import RememberTheMilkConfiguration
_LOGGER = logging.getLogger(__name__)
class RememberTheMilkEntity(Entity):
"""Representation of an interface to Remember The Milk."""
def __init__(
self,
name: str,
client: AioRTMClient,
rtm_config: RememberTheMilkConfiguration,
) -> None:
def __init__(self, name, api_key, shared_secret, token, rtm_config):
"""Create new instance of Remember The Milk component."""
self._name = name
self._api_key = api_key
self._shared_secret = shared_secret
self._token = token
self._rtm_config = rtm_config
self._client = client
self._token_valid = False
self._rtm_api = Rtm(api_key, shared_secret, "delete", token)
self._token_valid = None
self._check_token()
_LOGGER.debug("Instance created for account %s", self._name)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await self.check_token()
async def check_token(self) -> None:
def _check_token(self):
"""Check if the API token is still valid.
If it is not valid any more, delete it from the configuration. This
will trigger a new authentication process.
"""
try:
await self._client.rtm.api.check_token()
except AuthError as err:
LOGGER.error(
"Token for account %s is invalid. You need to register again: %s",
valid = self._rtm_api.token_valid()
if not valid:
_LOGGER.error(
"Token for account %s is invalid. You need to register again!",
self.name,
err,
)
except AioRTMError as err:
LOGGER.error(
"Error checking token for account %s. You need to register again: %s",
self.name,
err,
)
self._rtm_config.delete_token(self._name)
self._token_valid = False
else:
self._token_valid = True
return
return self._token_valid
self._rtm_config.delete_token(self._name)
self._token_valid = False
async def create_task(self, call: ServiceCall) -> None:
def create_task(self, call: ServiceCall) -> None:
"""Create a new task on Remember The Milk.
You can use the smart syntax to define the attributes of a new task,
@@ -71,70 +57,50 @@ class RememberTheMilkEntity(Entity):
rtm_id = None
if hass_id is not None:
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
if rtm_id is None:
rtm_id = await self._add_task(task_name)
LOGGER.debug(
if hass_id is None or rtm_id is None:
result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1"
)
_LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
)
if hass_id is not None:
self._rtm_config.set_rtm_id(
self._name,
hass_id,
rtm_id[0],
rtm_id[1],
rtm_id[2],
)
self._rtm_config.set_rtm_id(
self._name,
hass_id,
result.list.id,
result.list.taskseries.id,
result.list.taskseries.task.id,
)
else:
await self._rename_task(rtm_id, task_name)
LOGGER.debug(
self._rtm_api.rtm.tasks.setName(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
_LOGGER.debug(
"Updated task with id '%s' in account %s to name %s",
hass_id,
self.name,
task_name,
)
except AioRTMError as err:
LOGGER.error(
except RtmRequestFailedException as rtm_exception:
_LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
err,
rtm_exception,
)
async def _add_task(self, task_name: str) -> tuple[str, str, str]:
"""Add a task."""
timeline_response = await self._client.rtm.timelines.create()
timeline = timeline_response.timeline
task_response = await self._client.rtm.tasks.add(
timeline=timeline,
name=task_name,
parse=True,
)
task_list = task_response.task_list
task_list_id = task_list.id
task_series = task_list.taskseries[0]
task_series_id = task_series.id
task = task_series.task[0]
task_id = task.id
return (str(task_list_id), str(task_series_id), str(task_id))
async def _rename_task(self, rtm_id: tuple[str, str, str], task_name: str) -> None:
"""Rename a task."""
result = await self._client.rtm.timelines.create()
timeline = result.timeline
await self._client.rtm.tasks.set_name(
name=task_name,
list_id=int(rtm_id[0]),
taskseries_id=int(rtm_id[1]),
task_id=int(rtm_id[2]),
timeline=timeline,
)
async def complete_task(self, call: ServiceCall) -> None:
def complete_task(self, call: ServiceCall) -> None:
"""Complete a task that was previously created by this component."""
hass_id = call.data[CONF_ID]
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
if rtm_id is None:
LOGGER.error(
_LOGGER.error(
(
"Could not find task with ID %s in account %s. "
"So task could not be closed"
@@ -144,36 +110,32 @@ class RememberTheMilkEntity(Entity):
)
return
try:
await self._complete_task(rtm_id)
except AioRTMError as err:
LOGGER.error(
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.complete(
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
self._rtm_config.delete_rtm_id(self._name, hass_id)
_LOGGER.debug(
"Completed task with id %s in account %s", hass_id, self._name
)
except RtmRequestFailedException as rtm_exception:
_LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
err,
rtm_exception,
)
return
self._rtm_config.delete_rtm_id(self._name, hass_id)
LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
async def _complete_task(self, rtm_id: tuple[str, str, str]) -> None:
"""Complete a task."""
result = await self._client.rtm.timelines.create()
timeline = result.timeline
await self._client.rtm.tasks.complete(
list_id=int(rtm_id[0]),
taskseries_id=int(rtm_id[1]),
task_id=int(rtm_id[2]),
timeline=timeline,
)
@property
def name(self) -> str:
def name(self):
"""Return the name of the device."""
return self._name
@property
def state(self) -> str:
def state(self):
"""Return the state of the device."""
if not self._token_valid:
return "API token invalid"

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