mirror of
https://github.com/home-assistant/core.git
synced 2026-01-10 01:27:16 +01:00
Compare commits
93 Commits
list_expos
...
use-aiortm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f218c37364 | ||
|
|
d2a660714f | ||
|
|
800f680bd5 | ||
|
|
26c60880e4 | ||
|
|
059a6dddbe | ||
|
|
0f7cb6b757 | ||
|
|
8068f82888 | ||
|
|
d522571308 | ||
|
|
debee25086 | ||
|
|
508b6c8db0 | ||
|
|
97a124b28a | ||
|
|
800749728b | ||
|
|
b73c6ed768 | ||
|
|
1d43cb3f29 | ||
|
|
56e36cb1ff | ||
|
|
4f43c971cd | ||
|
|
113e703d5c | ||
|
|
e59ec8f867 | ||
|
|
b35d252549 | ||
|
|
71bdd0e237 | ||
|
|
9105542bab | ||
|
|
9cbed483fb | ||
|
|
c687f37539 | ||
|
|
97b853e2ea | ||
|
|
9d241a77b7 | ||
|
|
1cae504cfe | ||
|
|
509add8e5c | ||
|
|
97bf557b32 | ||
|
|
aec7fc1835 | ||
|
|
ab299d2bf7 | ||
|
|
490e012e54 | ||
|
|
e8ff31b792 | ||
|
|
5f98d5a65a | ||
|
|
5d1eb69281 | ||
|
|
ec7ec993b0 | ||
|
|
ff4f4111d0 | ||
|
|
66f293c8f3 | ||
|
|
8826714704 | ||
|
|
f828b4e0b9 | ||
|
|
73442e8443 | ||
|
|
0d8c449ff4 | ||
|
|
fb57284561 | ||
|
|
b856de225d | ||
|
|
9f7c4648a2 | ||
|
|
2d0967994e | ||
|
|
d2bd45099b | ||
|
|
6d6dfce7d1 | ||
|
|
d9a18c2994 | ||
|
|
affec21a6a | ||
|
|
94869f3210 | ||
|
|
e53617a788 | ||
|
|
e916b57714 | ||
|
|
119b296c26 | ||
|
|
20f273f06a | ||
|
|
6aae319b1a | ||
|
|
b3e245687c | ||
|
|
1a56dcfdaf | ||
|
|
66af5ca1e9 | ||
|
|
d24a14442f | ||
|
|
c7169a4ed7 | ||
|
|
08358514b4 | ||
|
|
1392bab4d5 | ||
|
|
e79a1a52c3 | ||
|
|
872cca9935 | ||
|
|
1bf7e5d749 | ||
|
|
2f7a8b4d9d | ||
|
|
0949f7d0ba | ||
|
|
a2ceeb19dc | ||
|
|
1c3d6b5641 | ||
|
|
14375e76a3 | ||
|
|
e5c0183e0f | ||
|
|
5c8fa717bf | ||
|
|
5d851b6a56 | ||
|
|
5dfd358fc9 | ||
|
|
901011de7b | ||
|
|
ad7780291e | ||
|
|
eb6993f0a8 | ||
|
|
406f894dc1 | ||
|
|
0a0a96fb3b | ||
|
|
354855ff5f | ||
|
|
8e6f2e6ff2 | ||
|
|
0b6f49fec2 | ||
|
|
b2e2ef3119 | ||
|
|
e360348525 | ||
|
|
4ed4c2cc5c | ||
|
|
bc5146db3c | ||
|
|
f98e83514d | ||
|
|
e847a8d6a5 | ||
|
|
7117708937 | ||
|
|
d2ce89882b | ||
|
|
1d3fcc67b8 | ||
|
|
32b854515b | ||
|
|
6c3a9cb1a8 |
100
.github/copilot-instructions.md
vendored
Normal file
100
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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
|
||||
6
.github/workflows/builder.yml
vendored
6
.github/workflows/builder.yml
vendored
@@ -324,7 +324,7 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3.8.0
|
||||
uses: sigstore/cosign-installer@v3.8.1
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
@@ -509,7 +509,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
|
||||
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.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@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
|
||||
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
44
.github/workflows/ci.yaml
vendored
44
.github/workflows/ci.yaml
vendored
@@ -240,7 +240,7 @@ jobs:
|
||||
check-latest: true
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@v4.2.0
|
||||
uses: actions/cache@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
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.0
|
||||
uses: actions/cache/restore@v4.2.1
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.1
|
||||
rev: v0.9.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
||||
@@ -438,6 +438,7 @@ 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.*
|
||||
|
||||
10
.vscode/launch.json
vendored
10
.vscode/launch.json
vendored
@@ -42,6 +42,14 @@
|
||||
"--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/
|
||||
@@ -77,4 +85,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1342,6 +1342,8 @@ 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
homeassistant/brands/sensorpush.json
Normal file
5
homeassistant/brands/sensorpush.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "sensorpush",
|
||||
"name": "SensorPush",
|
||||
"integrations": ["sensorpush", "sensorpush_cloud"]
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: 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)
|
||||
|
||||
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):
|
||||
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))
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arcam"],
|
||||
"requirements": ["arcam-fmj==1.8.0"],
|
||||
"requirements": ["arcam-fmj==1.8.1"],
|
||||
"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 Any, Literal, cast
|
||||
from typing import TYPE_CHECKING, 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 MATCH_ALL
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import chat_session, intent
|
||||
@@ -81,6 +81,9 @@ 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"
|
||||
@@ -123,6 +126,12 @@ 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,
|
||||
@@ -1084,10 +1093,22 @@ 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
|
||||
self.hass,
|
||||
user_input,
|
||||
intent_filter=intent_filter,
|
||||
)
|
||||
):
|
||||
# Local intent matched
|
||||
|
||||
@@ -16,7 +16,7 @@ from .agent import (
|
||||
BackupAgentPlatformProtocol,
|
||||
LocalBackupAgent,
|
||||
)
|
||||
from .config import BackupConfig
|
||||
from .config import BackupConfig, CreateBackupParametersDict
|
||||
from .const import DATA_MANAGER, DOMAIN
|
||||
from .http import async_register_http_views
|
||||
from .manager import (
|
||||
@@ -55,6 +55,7 @@ __all__ = [
|
||||
"BackupReaderWriter",
|
||||
"BackupReaderWriterError",
|
||||
"CreateBackupEvent",
|
||||
"CreateBackupParametersDict",
|
||||
"CreateBackupStage",
|
||||
"CreateBackupState",
|
||||
"Folder",
|
||||
|
||||
@@ -154,7 +154,8 @@ class BackupConfig:
|
||||
self.data.retention.apply(self._manager)
|
||||
self.data.schedule.apply(self._manager)
|
||||
|
||||
async def update(
|
||||
@callback
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
|
||||
|
||||
@@ -1870,7 +1870,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
and "hassio.local" in create_backup.agent_ids
|
||||
):
|
||||
automatic_agents = [self._local_agent_id, *automatic_agents]
|
||||
await config.update(
|
||||
config.update(
|
||||
create_backup=CreateBackupParametersDict(
|
||||
agent_ids=automatic_agents,
|
||||
include_addons=None,
|
||||
|
||||
@@ -104,12 +104,15 @@ 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, data["date"]),
|
||||
extra_metadata=cast(dict[str, bool | str], data.get("extra", {})),
|
||||
date=cast(str, date),
|
||||
extra_metadata=extra_metadata,
|
||||
folders=folders,
|
||||
homeassistant_included=homeassistant_included,
|
||||
homeassistant_version=homeassistant_version,
|
||||
|
||||
@@ -346,6 +346,7 @@ async def handle_config_info(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
@@ -387,8 +388,7 @@ async def handle_config_info(
|
||||
),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def handle_config_update(
|
||||
def handle_config_update(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
@@ -398,7 +398,7 @@ async def handle_config_update(
|
||||
changes = dict(msg)
|
||||
changes.pop("id")
|
||||
changes.pop("type")
|
||||
await manager.config.update(**changes)
|
||||
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.8.1",
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bluetooth-auto-recovery==1.4.2",
|
||||
"bluetooth-auto-recovery==1.4.4",
|
||||
"bluetooth-data-tools==1.23.4",
|
||||
"dbus-fast==2.33.0",
|
||||
"habluetooth==3.22.0"
|
||||
"habluetooth==3.22.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": "Start increasing the brightness of the light. (deprecated)."
|
||||
"description": "Starts increasing the brightness of the light (deprecated)."
|
||||
},
|
||||
"start_decreasing_brightness": {
|
||||
"name": "Start decreasing brightness",
|
||||
"description": "Start decreasing the brightness of the light. (deprecated)."
|
||||
"description": "Starts decreasing the brightness of the light (deprecated)."
|
||||
},
|
||||
"stop": {
|
||||
"name": "[%key:common::action::stop%]",
|
||||
"description": "Stop any in-progress action and empty the queue. (deprecated)."
|
||||
"description": "Stops any in-progress action and empty the queue (deprecated)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
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
|
||||
@@ -241,7 +243,10 @@ async def async_handle_sentence_triggers(
|
||||
|
||||
|
||||
async def async_handle_intents(
|
||||
hass: HomeAssistant, user_input: ConversationInput
|
||||
hass: HomeAssistant,
|
||||
user_input: ConversationInput,
|
||||
*,
|
||||
intent_filter: Callable[[RecognizeResult], bool] | None = None,
|
||||
) -> intent.IntentResponse | None:
|
||||
"""Try to match input against registered intents and return response.
|
||||
|
||||
@@ -250,7 +255,9 @@ 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)
|
||||
return await default_agent.async_handle_intents(
|
||||
user_input, intent_filter=intent_filter
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
@@ -185,21 +185,6 @@ 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],
|
||||
@@ -1324,6 +1309,8 @@ 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.
|
||||
|
||||
@@ -1331,7 +1318,9 @@ 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):
|
||||
if not isinstance(result, RecognizeResult) or (
|
||||
intent_filter is not None and intent_filter(result)
|
||||
):
|
||||
# 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.1.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: 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)
|
||||
|
||||
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):
|
||||
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))
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
from .entity import EnvoyBaseEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -132,6 +132,7 @@ 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(
|
||||
@@ -185,6 +186,7 @@ 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,3 +5,4 @@ ATTR_STATION = "station"
|
||||
CONF_STATION = "station"
|
||||
CONF_TITLE = "title"
|
||||
DOMAIN = "environment_canada"
|
||||
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"services": {
|
||||
"set_radar_type": {
|
||||
"service": "mdi:radar"
|
||||
},
|
||||
"get_forecasts": {
|
||||
"service": "mdi:weather-cloudy-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
get_forecasts:
|
||||
target:
|
||||
entity:
|
||||
integration: environment_canada
|
||||
domain: weather
|
||||
|
||||
set_radar_type:
|
||||
target:
|
||||
entity:
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
}
|
||||
},
|
||||
"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,11 +35,16 @@ from homeassistant.const import (
|
||||
UnitOfSpeed,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import entity_platform, entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, SERVICE_ENVIRONMENT_CANADA_FORECASTS
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
|
||||
|
||||
# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/
|
||||
@@ -78,6 +83,14 @@ 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."""
|
||||
@@ -185,6 +198,23 @@ 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, ConfigEntryState
|
||||
from homeassistant.config_entries import SOURCE_REAUTH
|
||||
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,8 +108,7 @@ class ESPHomeDashboardManager:
|
||||
|
||||
reloads = [
|
||||
hass.config_entries.async_reload(entry.entry_id)
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state is ConfigEntryState.LOADED
|
||||
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
]
|
||||
# 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,7 +111,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
else:
|
||||
await self.device.set_air_temp_setpoint_home(temperature)
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise HomeAssistantError from exc
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_temperature",
|
||||
translation_placeholders={
|
||||
"temperature": str(temperature),
|
||||
},
|
||||
) 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": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["flexit_bacnet==2.2.3"]
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration doesn't require any form of authentication.
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
# Gold
|
||||
entity-translations: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -130,6 +130,9 @@
|
||||
"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}."
|
||||
},
|
||||
|
||||
@@ -85,6 +85,8 @@ 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"
|
||||
@@ -135,11 +137,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
target_temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
elif target_temp is not None:
|
||||
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
|
||||
await self.hass.async_add_executor_job(
|
||||
self.data.set_target_temperature, target_temp, True
|
||||
)
|
||||
@@ -169,12 +173,12 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="change_hvac_while_active_mode",
|
||||
)
|
||||
if self.hvac_mode == hvac_mode:
|
||||
if self.hvac_mode is hvac_mode:
|
||||
LOGGER.debug(
|
||||
"%s is already in requested hvac mode %s", self.name, hvac_mode
|
||||
)
|
||||
return
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE)
|
||||
else:
|
||||
if value_scheduled_preset(self.data) == PRESET_ECO:
|
||||
@@ -208,16 +212,6 @@ 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.14"],
|
||||
"requirements": ["pyfritzhome==0.6.15"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-upnp-org:device:fritzbox:1"
|
||||
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
@@ -119,12 +119,13 @@ 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)
|
||||
)
|
||||
+ 1
|
||||
pos = self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == previous_uid)
|
||||
)
|
||||
if pos < self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == uid)
|
||||
):
|
||||
pos += 1
|
||||
else:
|
||||
pos = 0
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from homeassistant.components.backup import (
|
||||
BackupReaderWriter,
|
||||
BackupReaderWriterError,
|
||||
CreateBackupEvent,
|
||||
CreateBackupParametersDict,
|
||||
CreateBackupStage,
|
||||
CreateBackupState,
|
||||
Folder,
|
||||
@@ -635,7 +636,25 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
unsub()
|
||||
|
||||
async def async_validate_config(self, *, config: BackupConfig) -> None:
|
||||
"""Validate backup config."""
|
||||
"""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)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_listen_job_events(
|
||||
|
||||
@@ -5,24 +5,22 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions
|
||||
from pyheos import (
|
||||
CommandAuthenticationError,
|
||||
ConnectionState,
|
||||
Heos,
|
||||
HeosError,
|
||||
HeosOptions,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.config_entries import 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 (
|
||||
ATTR_UPNP_FRIENDLY_NAME,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, ENTRY_TITLE
|
||||
from .coordinator import HeosConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,11 +35,6 @@ 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))
|
||||
@@ -56,13 +49,19 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool:
|
||||
|
||||
|
||||
async def _validate_auth(
|
||||
user_input: dict[str, str], heos: Heos, errors: dict[str, str]
|
||||
user_input: dict[str, str], entry: HeosConfigEntry, 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 heos.sign_out()
|
||||
await entry.runtime_data.heos.sign_out()
|
||||
except HeosError:
|
||||
errors["base"] = "unknown"
|
||||
_LOGGER.exception("Unexpected error occurred during sign-out")
|
||||
@@ -81,8 +80,12 @@ async def _validate_auth(
|
||||
return False
|
||||
|
||||
# Attempt to login (both username and password provided)
|
||||
if not can_validate:
|
||||
return True
|
||||
try:
|
||||
await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
|
||||
await entry.runtime_data.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)
|
||||
@@ -94,16 +97,32 @@ async def _validate_auth(
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Successfully signed-in to HEOS Account: %s",
|
||||
heos.signed_in_username,
|
||||
entry.runtime_data.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:
|
||||
@@ -117,40 +136,84 @@ 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
|
||||
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")
|
||||
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")
|
||||
|
||||
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=format_title(host), data={CONF_HOST: host}
|
||||
title=ENTRY_TITLE, 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): host_type}),
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -186,8 +249,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
entry: HeosConfigEntry = self._get_reauth_entry()
|
||||
if user_input is not None:
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
|
||||
if await _validate_auth(user_input, entry, errors):
|
||||
return self.async_update_reload_and_abort(entry, options=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -208,8 +270,7 @@ class HeosOptionsFlowHandler(OptionsFlow):
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
entry: HeosConfigEntry = self.config_entry
|
||||
if await _validate_auth(user_input, entry.runtime_data.heos, errors):
|
||||
if await _validate_auth(user_input, self.config_entry, errors):
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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,7 +9,6 @@
|
||||
"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,9 +38,7 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: todo
|
||||
comment: Explore if this is possible.
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"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.",
|
||||
@@ -43,6 +47,7 @@
|
||||
},
|
||||
"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,9 +124,6 @@ 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
|
||||
@@ -455,30 +452,6 @@ 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,12 +28,13 @@ 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_type,
|
||||
probe_silabs_firmware_info,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -52,7 +53,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Instantiate base flow."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._probed_firmware_type: ApplicationType | None = None
|
||||
self._probed_firmware_info: FirmwareInfo | None = None
|
||||
self._device: str | None = None # To be set in a subclass
|
||||
self._hardware_name: str = "unknown" # To be set in a subclass
|
||||
|
||||
@@ -64,8 +65,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Shared translation placeholders."""
|
||||
placeholders = {
|
||||
"firmware_type": (
|
||||
self._probed_firmware_type.value
|
||||
if self._probed_firmware_type is not None
|
||||
self._probed_firmware_info.firmware_type.value
|
||||
if self._probed_firmware_info is not None
|
||||
else "unknown"
|
||||
),
|
||||
"model": self._hardware_name,
|
||||
@@ -120,39 +121,49 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
async def _probe_firmware_type(self) -> bool:
|
||||
"""Probe the firmware currently on the device."""
|
||||
assert self._device is not None
|
||||
|
||||
self._probed_firmware_type = await probe_silabs_firmware_type(
|
||||
self._device,
|
||||
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 (
|
||||
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:
|
||||
"""Probe the firmware currently on the device."""
|
||||
assert self._device is not None
|
||||
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
probe_methods=probe_methods,
|
||||
)
|
||||
|
||||
return (
|
||||
self._probed_firmware_info is not None
|
||||
and self._probed_firmware_info.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_type():
|
||||
if not await self._probe_firmware_info():
|
||||
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_type == ApplicationType.EZSP:
|
||||
if (
|
||||
self._probed_firmware_info is not None
|
||||
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
|
||||
):
|
||||
return await self.async_step_confirm_zigbee()
|
||||
|
||||
if not is_hassio(self.hass):
|
||||
@@ -338,7 +349,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Confirm Zigbee setup."""
|
||||
assert self._device is not None
|
||||
assert self._hardware_name is not None
|
||||
self._probed_firmware_type = ApplicationType.EZSP
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
await self.hass.config_entries.flow.async_init(
|
||||
@@ -366,7 +382,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Thread firmware."""
|
||||
if not await self._probe_firmware_type():
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
@@ -458,7 +474,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Confirm OTBR setup."""
|
||||
assert self._device is not None
|
||||
|
||||
self._probed_firmware_type = ApplicationType.SPINEL
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
# OTBR discovery is done automatically via hassio
|
||||
@@ -497,14 +517,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.25"]
|
||||
"requirements": ["universal-silabs-flasher==0.0.29"]
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class ApplicationType(StrEnum):
|
||||
CPC = "cpc"
|
||||
EZSP = "ezsp"
|
||||
SPINEL = "spinel"
|
||||
ROUTER = "router"
|
||||
|
||||
@classmethod
|
||||
def from_flasher_application_type(
|
||||
@@ -248,10 +249,10 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
|
||||
return guesses[-1][0]
|
||||
|
||||
|
||||
async def probe_silabs_firmware_type(
|
||||
async def probe_silabs_firmware_info(
|
||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
||||
) -> ApplicationType | None:
|
||||
"""Probe the running firmware on a Silabs device."""
|
||||
) -> FirmwareInfo | None:
|
||||
"""Probe the running firmware on a SiLabs device."""
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
**(
|
||||
@@ -269,4 +270,26 @@ async def probe_silabs_firmware_type(
|
||||
if flasher.app_type is None:
|
||||
return None
|
||||
|
||||
return ApplicationType.from_flasher_application_type(flasher.app_type)
|
||||
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
|
||||
|
||||
@@ -10,7 +10,10 @@ from homeassistant.components.homeassistant_hardware import (
|
||||
firmware_config_flow,
|
||||
silabs_multiprotocol_addon,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import ApplicationType
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryBaseFlow,
|
||||
@@ -118,7 +121,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_type is not None
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self._hw_variant.full_name,
|
||||
@@ -130,7 +133,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_type.value,
|
||||
"firmware": self._probed_firmware_info.firmware_type.value,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -203,18 +206,26 @@ 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_type is not None
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry=self.config_entry,
|
||||
data={
|
||||
**self.config_entry.data,
|
||||
"firmware": self._probed_firmware_type.value,
|
||||
"firmware": self._probed_firmware_info.firmware_type.value,
|
||||
},
|
||||
options=self.config_entry.options,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
||||
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
|
||||
SerialPortSettings as MultiprotocolSerialPortSettings,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import ApplicationType
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_HARDWARE,
|
||||
ConfigEntry,
|
||||
@@ -79,10 +82,13 @@ 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_type()
|
||||
await self._probe_firmware_info()
|
||||
|
||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||
if self._probed_firmware_type is ApplicationType.EZSP:
|
||||
if (
|
||||
self._probed_firmware_info is not None
|
||||
and self._probed_firmware_info.firmware_type is ApplicationType.EZSP
|
||||
):
|
||||
discovery_flow.async_create_flow(
|
||||
self.hass,
|
||||
ZHA_DOMAIN,
|
||||
@@ -98,7 +104,11 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
|
||||
title=BOARD_NAME,
|
||||
data={
|
||||
# Assume the firmware type is EZSP if we cannot probe it
|
||||
FIRMWARE: (self._probed_firmware_type or ApplicationType.EZSP).value,
|
||||
FIRMWARE: (
|
||||
self._probed_firmware_info.firmware_type
|
||||
if self._probed_firmware_info is not None
|
||||
else ApplicationType.EZSP
|
||||
).value,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -264,6 +274,14 @@ 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()
|
||||
|
||||
@@ -285,13 +303,13 @@ class HomeAssistantYellowOptionsFlowHandler(
|
||||
|
||||
def _async_flow_finished(self) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
assert self._probed_firmware_type is not None
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry=self.config_entry,
|
||||
data={
|
||||
**self.config_entry.data,
|
||||
FIRMWARE: self._probed_firmware_type.value,
|
||||
FIRMWARE: self._probed_firmware_info.firmware_type.value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,13 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.COVER, Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
type HomeeConfigEntry = ConfigEntry[Homee]
|
||||
|
||||
|
||||
78
homeassistant/components/homee/button.py
Normal file
78
homeassistant/components/homee/button.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""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)
|
||||
@@ -76,6 +76,7 @@ CLIMATE_PROFILES = [
|
||||
NodeProfile.WIFI_RADIATOR_THERMOSTAT,
|
||||
NodeProfile.WIFI_ROOM_THERMOSTAT,
|
||||
]
|
||||
|
||||
LIGHT_PROFILES = [
|
||||
NodeProfile.DIMMABLE_COLOR_LIGHT,
|
||||
NodeProfile.DIMMABLE_COLOR_METERING_PLUG,
|
||||
|
||||
213
homeassistant/components/homee/light.py
Normal file
213
homeassistant/components/homee/light.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""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
|
||||
@@ -157,7 +157,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
|
||||
AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription(
|
||||
key="rainfall_day",
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
|
||||
key="humidity",
|
||||
|
||||
@@ -26,6 +26,46 @@
|
||||
}
|
||||
},
|
||||
"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}"
|
||||
|
||||
@@ -4,17 +4,20 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from inkbird_ble import INKBIRDBluetoothDeviceData
|
||||
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfo,
|
||||
)
|
||||
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
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_DEVICE_TYPE, DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
@@ -25,20 +28,33 @@ 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
|
||||
data = INKBIRDBluetoothDeviceData()
|
||||
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
|
||||
PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=data.update,
|
||||
)
|
||||
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,
|
||||
)
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(
|
||||
coordinator.async_start()
|
||||
) # only start after all platforms have had a chance to subscribe
|
||||
# only start after all platforms have had a chance to subscribe
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""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.5.8"]
|
||||
"requirements": ["inkbird-ble==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"services": {
|
||||
"select_next": {
|
||||
"name": "Next",
|
||||
"description": "Select the next option.",
|
||||
"description": "Selects 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
|
||||
from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH
|
||||
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
|
||||
from .entity import JellyfinClientEntity
|
||||
|
||||
@@ -169,7 +169,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
if self.now_playing is None:
|
||||
return None
|
||||
|
||||
return get_artwork_url(self.coordinator.api_client, self.now_playing, 150)
|
||||
return get_artwork_url(
|
||||
self.coordinator.api_client, self.now_playing, MAX_IMAGE_WIDTH
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -26,11 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""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)
|
||||
"""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):
|
||||
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))
|
||||
|
||||
1
homeassistant/components/linak/__init__.py
Normal file
1
homeassistant/components/linak/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""LINAK virtual integration."""
|
||||
6
homeassistant/components/linak/manifest.json
Normal file
6
homeassistant/components/linak/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"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, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
@@ -43,14 +43,16 @@ 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,6 +277,21 @@ 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,
|
||||
@@ -288,6 +303,7 @@ 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 = {
|
||||
@@ -300,6 +316,7 @@ 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 = {
|
||||
@@ -312,6 +329,7 @@ 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]] = {
|
||||
@@ -326,6 +344,7 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: 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)
|
||||
|
||||
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):
|
||||
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))
|
||||
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
@@ -56,11 +55,9 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
|
||||
|
||||
Will raise an HTTP error if the expected configuration is not present.
|
||||
"""
|
||||
config_entries: list[MCPServerConfigEntry] = [
|
||||
config_entry
|
||||
for config_entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
config_entries: list[MCPServerConfigEntry] = (
|
||||
hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
)
|
||||
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": "Shuffle",
|
||||
"description": "Playback mode that selects the media in randomized order.",
|
||||
"name": "Set shuffle",
|
||||
"description": "Enables or disables the shuffle mode.",
|
||||
"fields": {
|
||||
"shuffle": {
|
||||
"name": "Shuffle",
|
||||
"description": "Whether or not shuffle mode is enabled."
|
||||
"name": "Shuffle mode",
|
||||
"description": "Whether the media should be played in randomized order or not."
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeat_set": {
|
||||
"name": "Repeat",
|
||||
"description": "Playback mode that plays the media in a loop.",
|
||||
"name": "Set repeat",
|
||||
"description": "Sets the repeat mode.",
|
||||
"fields": {
|
||||
"repeat": {
|
||||
"name": "Repeat mode",
|
||||
"description": "Repeat mode to set."
|
||||
"description": "Whether the media (one or all) should be played in a loop or not."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "Slave",
|
||||
"description": "Address of the modbus unit/slave."
|
||||
"name": "Server",
|
||||
"description": "Address of the Modbus unit/server."
|
||||
},
|
||||
"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%]",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: 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)
|
||||
|
||||
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):
|
||||
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))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"services": {
|
||||
"aux": {
|
||||
"name": "Aux",
|
||||
"description": "Trigger an aux output.",
|
||||
"description": "Changes the state of an aux output.",
|
||||
"fields": {
|
||||
"output_id": {
|
||||
"name": "Output ID",
|
||||
@@ -10,17 +10,17 @@
|
||||
},
|
||||
"state": {
|
||||
"name": "State",
|
||||
"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."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"panic": {
|
||||
"name": "Panic",
|
||||
"description": "Triggers a panic.",
|
||||
"description": "Triggers a panic alarm.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "Code",
|
||||
"description": "The user code to use to trigger the panic."
|
||||
"description": "The user code to use to trigger the panic alarm."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -84,8 +83,7 @@ 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_entries(DOMAIN)
|
||||
if config_entry.state == ConfigEntryState.LOADED
|
||||
for config_entry in hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
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,6 +99,11 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -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 DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -205,8 +205,12 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
|
||||
backup = backups[backup_id]
|
||||
|
||||
await self._client.delete_drive_item(backup.backup_file_id)
|
||||
await self._client.delete_drive_item(backup.metadata_file_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
|
||||
)
|
||||
self._cache_expiration = time()
|
||||
|
||||
@handle_backup_errors
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""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
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
|
||||
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 DOMAIN, OAUTH_SCOPES
|
||||
from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
|
||||
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
@@ -86,3 +91,38 @@ 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,6 +7,8 @@ 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.10"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.11"]
|
||||
}
|
||||
|
||||
@@ -30,10 +30,7 @@ rules:
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
No Options flow.
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
|
||||
@@ -29,6 +29,19 @@
|
||||
"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",
|
||||
|
||||
@@ -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 as err:
|
||||
except (RequestError, Fault) as err:
|
||||
LOGGER.warning(
|
||||
"Couldn't get device '%s' date/time. Error: %s", self.name, err
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
from opower import Forecast, MeterType, UnitOfMeasure
|
||||
|
||||
@@ -28,7 +29,7 @@ from .coordinator import OpowerConfigEntry, OpowerCoordinator
|
||||
class OpowerEntityDescription(SensorEntityDescription):
|
||||
"""Class describing Opower sensors entities."""
|
||||
|
||||
value_fn: Callable[[Forecast], str | float]
|
||||
value_fn: Callable[[Forecast], str | float | date]
|
||||
|
||||
|
||||
# suggested_display_precision=0 for all sensors since
|
||||
@@ -96,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: str(data.start_date),
|
||||
value_fn=lambda data: data.start_date,
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="elec_end_date",
|
||||
@@ -104,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: str(data.end_date),
|
||||
value_fn=lambda data: data.end_date,
|
||||
),
|
||||
)
|
||||
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
@@ -168,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: str(data.start_date),
|
||||
value_fn=lambda data: data.start_date,
|
||||
),
|
||||
OpowerEntityDescription(
|
||||
key="gas_end_date",
|
||||
@@ -176,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: str(data.end_date),
|
||||
value_fn=lambda data: data.end_date,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -246,7 +247,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
|
||||
self.utility_account_id = utility_account_id
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self) -> StateType | date:
|
||||
"""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.9"]
|
||||
"requirements": ["pyprosegur==0.0.13"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -16,10 +15,8 @@ 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]
|
||||
@@ -45,24 +42,6 @@ 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,6 +18,8 @@ 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,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
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -59,14 +59,11 @@ 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 as e:
|
||||
except InvalidAuth:
|
||||
try:
|
||||
await self.pyload.login()
|
||||
except InvalidAuth as exc:
|
||||
@@ -75,13 +72,38 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
|
||||
translation_key="setup_authentication_exception",
|
||||
translation_placeholders={CONF_USERNAME: self.pyload.username},
|
||||
) from exc
|
||||
|
||||
raise UpdateFailed(
|
||||
"Unable to retrieve data due to cookie expiration"
|
||||
) from e
|
||||
_LOGGER.debug(
|
||||
"Unable to retrieve data due to cookie expiration, retrying after 20 seconds"
|
||||
)
|
||||
return self.data
|
||||
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.3.2"]
|
||||
"requirements": ["PyLoadAPI==1.4.1"]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ from .const import UNIT_DOWNLOADS
|
||||
from .coordinator import PyLoadConfigEntry, PyLoadData
|
||||
from .entity import BasePyLoadEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class PyLoadSensorEntity(StrEnum):
|
||||
"""pyLoad Sensor Entities."""
|
||||
|
||||
@@ -22,6 +22,8 @@ from .const import DOMAIN
|
||||
from .coordinator import PyLoadConfigEntry, PyLoadData
|
||||
from .entity import BasePyLoadEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class PyLoadSwitch(StrEnum):
|
||||
"""PyLoad Switch Entities."""
|
||||
|
||||
@@ -71,17 +71,14 @@ 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."""
|
||||
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)):
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN) and (
|
||||
config_coordinator := hass.data.get(QBUS_KEY)
|
||||
):
|
||||
config_coordinator.shutdown()
|
||||
|
||||
@@ -5,7 +5,10 @@ from typing import Final
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN: Final = "qbus"
|
||||
PLATFORMS: list[Platform] = [Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
CONF_SERIAL_NUMBER: Final = "serial"
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""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
|
||||
@@ -10,12 +13,36 @@ 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
homeassistant/components/qbus/light.py
Normal file
110
homeassistant/components/qbus/light.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""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)
|
||||
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import QbusConfigEntry
|
||||
from .entity import QbusEntity
|
||||
from .entity import QbusEntity, add_new_outputs
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -19,26 +19,21 @@ PARALLEL_UPDATES = 0
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: QbusConfigEntry,
|
||||
add_entities: AddConfigEntryEntitiesCallback,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switch entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
added_outputs: list[QbusMqttOutput] = []
|
||||
|
||||
# Local function that calls add_entities for new entities
|
||||
def _check_outputs() -> None:
|
||||
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])
|
||||
add_new_outputs(
|
||||
coordinator,
|
||||
added_outputs,
|
||||
lambda output: output.type == "onoff",
|
||||
QbusSwitch,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
_check_outputs()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
|
||||
@@ -49,10 +44,7 @@ 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)
|
||||
|
||||
@@ -43,6 +43,7 @@ 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
|
||||
@@ -183,7 +184,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[DOMAIN].db_connected
|
||||
db_connected: asyncio.Future[bool] = hass.data[DATA_RECORDER].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,6 +24,7 @@ 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
|
||||
@@ -561,7 +562,9 @@ 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[DOMAIN].recorder_platforms.items():
|
||||
for domain, platform in instance.hass.data[
|
||||
DATA_RECORDER
|
||||
].recorder_platforms.items():
|
||||
if not (
|
||||
platform_compile_statistics := getattr(
|
||||
platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None
|
||||
@@ -599,7 +602,7 @@ def _compile_statistics(
|
||||
|
||||
if start.minute == 50:
|
||||
# Once every hour, update issues
|
||||
for platform in instance.hass.data[DOMAIN].recorder_platforms.values():
|
||||
for platform in instance.hass.data[DATA_RECORDER].recorder_platforms.values():
|
||||
if not (
|
||||
platform_update_issues := getattr(
|
||||
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
|
||||
@@ -882,7 +885,7 @@ def list_statistic_ids(
|
||||
# the integrations for the missing ones.
|
||||
#
|
||||
# Query all integrations with a registered recorder platform
|
||||
for platform in hass.data[DOMAIN].recorder_platforms.values():
|
||||
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
|
||||
if not (
|
||||
platform_list_statistic_ids := getattr(
|
||||
platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None
|
||||
@@ -2232,7 +2235,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[DOMAIN].recorder_platforms.values():
|
||||
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
|
||||
if platform_validate_statistics := getattr(
|
||||
platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None
|
||||
):
|
||||
@@ -2243,7 +2246,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[DOMAIN].recorder_platforms.values():
|
||||
for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
|
||||
if platform_update_statistics_issues := getattr(
|
||||
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
|
||||
):
|
||||
|
||||
@@ -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[DOMAIN].recorder_platforms
|
||||
platforms: dict[str, Any] = hass.data[DATA_RECORDER].recorder_platforms
|
||||
platforms[domain] = platform
|
||||
|
||||
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
"""Support to interact with Remember The Milk."""
|
||||
"""The Remember The Milk integration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
from rtmapi import Rtm
|
||||
from aiortm import AioRTMClient, Auth, AuthError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import configurator
|
||||
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
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 CONF_SHARED_SECRET, DOMAIN, LOGGER
|
||||
from .entity import RememberTheMilkEntity
|
||||
|
||||
# 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"
|
||||
from .storage import RememberTheMilkConfiguration
|
||||
|
||||
RTM_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -41,7 +36,6 @@ 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"
|
||||
|
||||
@@ -51,205 +45,106 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
|
||||
|
||||
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
|
||||
|
||||
DATA_COMPONENT = "component"
|
||||
DATA_ENTITY_ID = "entity_id"
|
||||
DATA_STORAGE = "storage"
|
||||
|
||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Remember the milk component."""
|
||||
component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass)
|
||||
|
||||
stored_rtm_config = RememberTheMilkConfiguration(hass)
|
||||
hass.data[DOMAIN] = {}
|
||||
hass.data[DOMAIN][DATA_COMPONENT] = EntityComponent[RememberTheMilkEntity](
|
||||
LOGGER, DOMAIN, hass
|
||||
)
|
||||
storage = hass.data[DOMAIN][DATA_STORAGE] = RememberTheMilkConfiguration(hass)
|
||||
await hass.async_add_executor_job(storage.setup)
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
for rtm_config in config[DOMAIN]:
|
||||
account_name = rtm_config[CONF_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)
|
||||
_create_instance(
|
||||
hass,
|
||||
account_name,
|
||||
api_key,
|
||||
shared_secret,
|
||||
token,
|
||||
stored_rtm_config,
|
||||
component,
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=rtm_config,
|
||||
)
|
||||
else:
|
||||
_register_new_account(
|
||||
hass, account_name, api_key, shared_secret, stored_rtm_config, component
|
||||
)
|
||||
|
||||
_LOGGER.debug("Finished adding all Remember the milk accounts")
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Remember The Milk from a config entry."""
|
||||
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
|
||||
DATA_COMPONENT
|
||||
]
|
||||
storage: RememberTheMilkConfiguration = hass.data[DOMAIN][DATA_STORAGE]
|
||||
|
||||
rtm_config = entry.data
|
||||
account_name: str = rtm_config[CONF_USERNAME]
|
||||
LOGGER.debug("Adding Remember the milk account %s", account_name)
|
||||
api_key: str = rtm_config[CONF_API_KEY]
|
||||
shared_secret: str = rtm_config[CONF_SHARED_SECRET]
|
||||
token: str | None = rtm_config[CONF_TOKEN] # None if imported from YAML
|
||||
client = AioRTMClient(
|
||||
Auth(
|
||||
client_session=async_get_clientsession(hass),
|
||||
api_key=api_key,
|
||||
shared_secret=shared_secret,
|
||||
auth_token=token,
|
||||
permission="delete",
|
||||
)
|
||||
)
|
||||
component.add_entities([entity])
|
||||
hass.services.register(
|
||||
|
||||
token_valid = True
|
||||
try:
|
||||
await client.rtm.api.check_token()
|
||||
except AuthError as err:
|
||||
token_valid = False
|
||||
if entry.source == SOURCE_IMPORT:
|
||||
raise ConfigEntryAuthFailed("Missing token") from err
|
||||
|
||||
if (known_entity_ids := hass.data[DOMAIN].get(DATA_ENTITY_ID)) and (
|
||||
entity_id := known_entity_ids.get(account_name)
|
||||
):
|
||||
await component.async_remove_entity(entity_id)
|
||||
|
||||
# The entity will be deprecated when a todo platform is added.
|
||||
entity = RememberTheMilkEntity(
|
||||
name=account_name,
|
||||
client=client,
|
||||
config_entry_id=entry.entry_id,
|
||||
storage=storage,
|
||||
token_valid=token_valid,
|
||||
)
|
||||
await component.async_add_entities([entity])
|
||||
known_entity_ids = hass.data[DOMAIN].setdefault(DATA_ENTITY_ID, {})
|
||||
known_entity_ids[account_name] = entity.entity_id
|
||||
|
||||
# The services are registered here for now because they need the account name.
|
||||
# The services will be deprecated when a todo platform is added.
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
f"{account_name}_create_task",
|
||||
entity.create_task,
|
||||
schema=SERVICE_SCHEMA_CREATE_TASK,
|
||||
)
|
||||
hass.services.register(
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
f"{account_name}_complete_task",
|
||||
entity.complete_task,
|
||||
schema=SERVICE_SCHEMA_COMPLETE_TASK,
|
||||
)
|
||||
|
||||
if not token_valid:
|
||||
raise ConfigEntryAuthFailed("Invalid token")
|
||||
|
||||
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")
|
||||
|
||||
def register_account_callback(fields: list[dict[str, str]]) -> None:
|
||||
"""Call for register the configurator."""
|
||||
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
|
||||
|
||||
stored_rtm_config.set_token(account_name, token)
|
||||
_LOGGER.debug("Retrieved new token from server")
|
||||
|
||||
_create_instance(
|
||||
hass,
|
||||
account_name,
|
||||
api_key,
|
||||
shared_secret,
|
||||
token,
|
||||
stored_rtm_config,
|
||||
component,
|
||||
)
|
||||
|
||||
configurator.request_done(hass, request_id)
|
||||
|
||||
request_id = configurator.request_config(
|
||||
hass,
|
||||
f"{DOMAIN} - {account_name}",
|
||||
callback=register_account_callback,
|
||||
description=(
|
||||
"You need to log in to Remember The Milk to"
|
||||
"connect your account. \n\n"
|
||||
"Step 1: Click on the link 'Remember The Milk login'\n\n"
|
||||
"Step 2: Click on 'login completed'"
|
||||
),
|
||||
link_name="Remember The Milk login",
|
||||
link_url=url,
|
||||
submit_caption="login completed",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
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()
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
|
||||
DATA_COMPONENT
|
||||
]
|
||||
entity_id = hass.data[DOMAIN][DATA_ENTITY_ID].pop(entry.data[CONF_USERNAME])
|
||||
await component.async_remove_entity(entity_id)
|
||||
return True
|
||||
|
||||
157
homeassistant/components/remember_the_milk/config_flow.py
Normal file
157
homeassistant/components/remember_the_milk/config_flow.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Config flow for Remember The Milk integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from aiortm import Auth, AuthError, ResponseError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IMPORT,
|
||||
SOURCE_REAUTH,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
|
||||
|
||||
TOKEN_TIMEOUT_SEC = 30
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(CONF_SHARED_SECRET): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RTMConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Remember The Milk."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._auth: Auth | None = None
|
||||
self._url: str | None = None
|
||||
self._frob: str | None = None
|
||||
self._auth_data: dict[str, str] | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._auth_data = user_input
|
||||
auth = self._auth = Auth(
|
||||
client_session=async_get_clientsession(self.hass),
|
||||
api_key=user_input[CONF_API_KEY],
|
||||
shared_secret=user_input[CONF_SHARED_SECRET],
|
||||
permission="delete",
|
||||
)
|
||||
try:
|
||||
self._url, self._frob = await auth.authenticate_desktop()
|
||||
except AuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ResponseError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001 pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self.async_step_auth()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Authorize the application."""
|
||||
assert self._url is not None
|
||||
if user_input is not None:
|
||||
return await self._get_token()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="auth", description_placeholders={"url": self._url}
|
||||
)
|
||||
|
||||
async def _get_token(self) -> ConfigFlowResult:
|
||||
"""Get token and create config entry."""
|
||||
assert self._auth is not None
|
||||
assert self._frob is not None
|
||||
assert self._auth_data is not None
|
||||
try:
|
||||
async with asyncio.timeout(TOKEN_TIMEOUT_SEC):
|
||||
token = await self._auth.get_token(self._frob)
|
||||
except TimeoutError:
|
||||
return self.async_abort(reason="timeout_token")
|
||||
except AuthError:
|
||||
return self.async_abort(reason="invalid_auth")
|
||||
except ResponseError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception: # noqa: BLE001 pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
await self.async_set_unique_id(token["user"]["id"])
|
||||
data = {
|
||||
**self._auth_data,
|
||||
CONF_TOKEN: token["token"],
|
||||
CONF_USERNAME: token["user"]["username"],
|
||||
}
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if reauth_entry.source == SOURCE_IMPORT and reauth_entry.unique_id is None:
|
||||
# Imported entries do not have a token nor unique id.
|
||||
# Update unique id to match the new token.
|
||||
# This case can be removed when the import step is removed.
|
||||
self.hass.config_entries.async_update_entry(
|
||||
reauth_entry, data=data, unique_id=token["user"]["id"]
|
||||
)
|
||||
else:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates=data,
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=token["user"]["fullname"],
|
||||
data=data,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a config entry.
|
||||
|
||||
The token will be retrieved after config entry setup in a reauth flow.
|
||||
"""
|
||||
name = import_info.pop(CONF_NAME)
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data=import_info | {CONF_USERNAME: name, CONF_TOKEN: None},
|
||||
)
|
||||
7
homeassistant/components/remember_the_milk/const.py
Normal file
7
homeassistant/components/remember_the_milk/const.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Constants for the Remember The Milk integration."""
|
||||
|
||||
import logging
|
||||
|
||||
CONF_SHARED_SECRET = "shared_secret"
|
||||
DOMAIN = "remember_the_milk"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user