mirror of
https://github.com/home-assistant/core.git
synced 2026-05-03 19:41:15 +02:00
Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69a252092c | |||
| ec257a54f3 | |||
| 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 | |||
| 81c909e8ce | |||
| 85f44fa008 | |||
| fb3b23aef3 | |||
| b70c5710a9 | |||
| 600bfed704 | |||
| af0a862aab | |||
| 1733f5d3fb | |||
| 97c558b694 | |||
| d655c51ef9 | |||
| 618bdba4d3 | |||
| 38efe94def | |||
| 0c28b69269 | |||
| 36c7546e26 | |||
| 8d39f298c0 | |||
| 68085ed4f9 | |||
| d97194303a | |||
| b6cb2bfe5b | |||
| c5222708ed | |||
| 6cf31e0807 | |||
| ff83a14570 | |||
| 46599a4ac4 | |||
| 689421eddf | |||
| ee5e25aca6 | |||
| a6bb5dbe2a | |||
| f8ffbf0506 | |||
| 6613b46071 | |||
| 1579e90d58 | |||
| b71d5737a5 | |||
| 8e887f550e | |||
| 1af8b69dd6 | |||
| 6ef401251c | |||
| 141bcae793 | |||
| 8ae52cdc4c | |||
| 13fe2a9929 | |||
| df50863872 | |||
| 82ac3e3fdf | |||
| c48797804d | |||
| e6217efcd6 | |||
| 8dd1e9d101 | |||
| 096468baa4 | |||
| 3659fa4c4e | |||
| d1f0e0a70f | |||
| a45fb57595 | |||
| e9fcef1b57 | |||
| a003f89a5e | |||
| 22c634e626 | |||
| 46c604fcbe | |||
| 94d3b3919d | |||
| 350b935fa7 | |||
| e660096801 | |||
| f5e1fa6a21 | |||
| 800cdee409 | |||
| 33df208296 | |||
| 0dc1151a25 | |||
| 25865b4849 | |||
| 5658f9ca40 | |||
| f9047d0223 | |||
| bbfb9fbdae | |||
| 772e7147bd | |||
| 9ac60f1c7f | |||
| 3b6e3fe457 | |||
| da9fbf21df | |||
| d7e796e9f9 | |||
| e0795e6d07 | |||
| ff16e587e8 | |||
| 04b826daa1 | |||
| 25296e1b8f | |||
| 67fcbc4c28 | |||
| 34a33e0465 | |||
| 82f2e72327 | |||
| 9422c4de65 | |||
| 4cdc3de94a | |||
| 51aea58c7a | |||
| 7e388f69b0 | |||
| d8d054e7dd | |||
| 4a385ed26c | |||
| df6cb0b824 | |||
| a7f63e3847 | |||
| b4fac38d8a | |||
| 168e45b0f9 | |||
| 1fe644d056 | |||
| cd13eff8ae | |||
| e77193fa2e | |||
| 66d16336ea | |||
| ed3ca76696 | |||
| f2126a357a | |||
| 89956adf2e | |||
| c357b3ae65 | |||
| 6b90e7b2c2 | |||
| 56b51227bb | |||
| 93f1597e6d | |||
| bdeb24cb61 | |||
| 09df6c8706 | |||
| 0b7ec96448 | |||
| ccd0e27e84 | |||
| e0b50ee1e2 | |||
| 7063636db6 | |||
| f67fb9985e | |||
| 2d5e920de0 | |||
| 9e15a33c42 | |||
| e767863ea4 | |||
| 7f3270e982 | |||
| 95b1cf465b | |||
| 3ce8e1683a | |||
| 21032ea7cd | |||
| c75707ec79 | |||
| 9573f7828b | |||
| d435f7be09 | |||
| a3eb73cfcc | |||
| fdaa640c8e | |||
| 6059446ae3 | |||
| 827865a1b9 | |||
| 0a78f2725d | |||
| 78337a6846 | |||
| 78c4d815ce | |||
| 482df7408a | |||
| 05696b5528 | |||
| c89d8edb3c | |||
| 08f6e9cd12 | |||
| cbb0dee911 | |||
| 798d2326ed | |||
| 91ba9b2239 | |||
| 7a23348b1d | |||
| 30a6a6ad4b | |||
| 4a4c2ff552 | |||
| e16343ed72 | |||
| 28dd44504e | |||
| b916fbe1fc | |||
| 58797a14e7 | |||
| c090fbfbad | |||
| 2bfe96dded | |||
| d99044572a | |||
| 11aa08cf74 | |||
| 5dc1689e7c | |||
| 28ea55aac0 | |||
| 7bd2c1d710 | |||
| 23d43b23ee | |||
| 7dd678ccdf | |||
| 371490a470 | |||
| 48f58c7d49 | |||
| fae68c8ad5 | |||
| fa4ebeb680 | |||
| efd7ddeb89 | |||
| f407dbd35c | |||
| 4d3a4015ed | |||
| 9f9aeb4cce | |||
| b9148d6368 | |||
| d82dd9e7e6 | |||
| 51beb21fe4 | |||
| 83f8a4454d | |||
| 6a4f5188b1 | |||
| 099adebcb6 | |||
| 00e98954e4 | |||
| 621bcccef7 | |||
| d6b7762dd6 | |||
| bbbad90ca2 | |||
| ab2e075b41 | |||
| 2ea648f8ae | |||
| bf27eeb861 | |||
| d4c5479e50 | |||
| a03c588002 | |||
| 82074a8940 | |||
| 7021175e0d | |||
| e9138a427d | |||
| 6a26d59142 | |||
| a8f4ab73ae | |||
| 0a9d134f49 | |||
| 07c304125a | |||
| 6bc4f04a07 | |||
| 737baaef2b | |||
| 1ac16f6dbf | |||
| 81cac25bd0 | |||
| c0068e0891 | |||
| 641b487196 | |||
| 03b3097c34 | |||
| 400dbc8d1b | |||
| d9108cc003 | |||
| ff5ddce7b0 | |||
| 620141cfb1 | |||
| 8bf870f296 | |||
| 281c2bfb7b | |||
| 910711ecba | |||
| 4807682fc5 | |||
| 327bb34be1 | |||
| 6084bee2d5 | |||
| bc11444fb2 | |||
| e12b100a37 | |||
| ef9d5dd568 | |||
| 2bb582f8e6 | |||
| f1471f143c | |||
| 64fa9b78f8 | |||
| 88b444fa5b | |||
| 487a4ac5c4 | |||
| a3cde3d8ab | |||
| 6ef1178a35 | |||
| 2033dbdd90 | |||
| a6c51440e5 | |||
| 1393f417ed | |||
| da1e3c29ed | |||
| 117a71cb67 | |||
| 48b8ec01e3 | |||
| 0ffbe076be | |||
| 8d5f927b42 | |||
| 6abf7b525a | |||
| 6115def083 | |||
| 17089e822e | |||
| 7a556ac3ec | |||
| 857e35b7fd | |||
| 2cea2258a2 | |||
| 444b9a9579 | |||
| fe3d6f93d7 | |||
| 3489b20e86 | |||
| 8e7f35aa7d | |||
| 14e1b55b5a | |||
| a85bb98743 | |||
| ab1e1c06b6 | |||
| 6226542e4d | |||
| 7f376ff004 | |||
| 62b563eb60 | |||
| df4c718bac | |||
| 1a4738b1d4 | |||
| 8b3421deb7 |
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 99 KiB |
@@ -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
|
||||
@@ -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
|
||||
|
||||
+22
-22
@@ -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:
|
||||
|
||||
@@ -234,6 +234,7 @@ homeassistant.components.here_travel_time.*
|
||||
homeassistant.components.history.*
|
||||
homeassistant.components.history_stats.*
|
||||
homeassistant.components.holiday.*
|
||||
homeassistant.components.home_connect.*
|
||||
homeassistant.components.homeassistant.*
|
||||
homeassistant.components.homeassistant_alerts.*
|
||||
homeassistant.components.homeassistant_green.*
|
||||
@@ -437,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.*
|
||||
|
||||
Generated
+2
@@ -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
|
||||
|
||||
Generated
+19
-19
@@ -12,8 +12,26 @@ ENV \
|
||||
|
||||
ARG QEMU_CPU
|
||||
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
# Needs to be redefined inside the FROM statement to be set for RUN commands
|
||||
ARG BUILD_ARCH
|
||||
# Get go2rtc binary
|
||||
RUN \
|
||||
case "${BUILD_ARCH}" in \
|
||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||
"armhf") go2rtc_suffix='armv6' ;; \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.5.27
|
||||
RUN pip3 install uv==0.6.1
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
@@ -42,22 +60,4 @@ RUN \
|
||||
&& python3 -m compileall \
|
||||
homeassistant/homeassistant
|
||||
|
||||
# Home Assistant S6-Overlay
|
||||
COPY rootfs /
|
||||
|
||||
# Needs to be redefined inside the FROM statement to be set for RUN commands
|
||||
ARG BUILD_ARCH
|
||||
# Get go2rtc binary
|
||||
RUN \
|
||||
case "${BUILD_ARCH}" in \
|
||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||
"armhf") go2rtc_suffix='armv6' ;; \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
WORKDIR /config
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.12.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.12.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.12.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.12.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.12.0
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
|
||||
+60
-76
@@ -134,14 +134,12 @@ DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded")
|
||||
LOG_SLOW_STARTUP_INTERVAL = 60
|
||||
SLOW_STARTUP_CHECK_INTERVAL = 1
|
||||
|
||||
STAGE_0_SUBSTAGE_TIMEOUT = 60
|
||||
STAGE_1_TIMEOUT = 120
|
||||
STAGE_2_TIMEOUT = 300
|
||||
WRAP_UP_TIMEOUT = 300
|
||||
COOLDOWN_TIME = 60
|
||||
|
||||
|
||||
DEBUGGER_INTEGRATIONS = {"debugpy"}
|
||||
|
||||
# Core integrations are unconditionally loaded
|
||||
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
|
||||
|
||||
@@ -152,6 +150,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
|
||||
"isal",
|
||||
# Set log levels
|
||||
"logger",
|
||||
# Ensure network config is available
|
||||
# before hassio or any other integration is
|
||||
# loaded that might create an aiohttp client session
|
||||
"network",
|
||||
# Error logging
|
||||
"system_log",
|
||||
"sentry",
|
||||
@@ -172,12 +174,27 @@ FRONTEND_INTEGRATIONS = {
|
||||
# add it here.
|
||||
"backup",
|
||||
}
|
||||
RECORDER_INTEGRATIONS = {
|
||||
# Setup after frontend
|
||||
# To record data
|
||||
"recorder",
|
||||
}
|
||||
DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf")
|
||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
||||
# The substages preceding it should also have no timeout, until we ensure that the recorder
|
||||
# is not accidentally promoted as a dependency of any of the integrations in them.
|
||||
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
||||
STAGE_0_INTEGRATIONS = (
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
|
||||
# Setup frontend
|
||||
("frontend", FRONTEND_INTEGRATIONS, None),
|
||||
# Setup recorder
|
||||
("recorder", {"recorder"}, None),
|
||||
# Start up debuggers. Start these first in case they want to wait.
|
||||
("debugger", {"debugpy"}, STAGE_0_SUBSTAGE_TIMEOUT),
|
||||
# Zeroconf is used for mdns resolution in aiohttp client helper.
|
||||
("zeroconf", {"zeroconf"}, STAGE_0_SUBSTAGE_TIMEOUT),
|
||||
)
|
||||
|
||||
DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb")
|
||||
# Stage 1 integrations are not to be preimported in bootstrap.
|
||||
STAGE_1_INTEGRATIONS = {
|
||||
# We need to make sure discovery integrations
|
||||
# update their deps before stage 2 integrations
|
||||
@@ -189,9 +206,8 @@ STAGE_1_INTEGRATIONS = {
|
||||
"mqtt_eventstream",
|
||||
# To provide account link implementations
|
||||
"cloud",
|
||||
# Ensure supervisor is available
|
||||
"hassio",
|
||||
}
|
||||
|
||||
DEFAULT_INTEGRATIONS = {
|
||||
# These integrations are set up unless recovery mode is activated.
|
||||
#
|
||||
@@ -232,22 +248,12 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = {
|
||||
# These integrations are set up if using the Supervisor
|
||||
"hassio",
|
||||
}
|
||||
|
||||
CRITICAL_INTEGRATIONS = {
|
||||
# Recovery mode is activated if these integrations fail to set up
|
||||
"frontend",
|
||||
}
|
||||
|
||||
SETUP_ORDER = (
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
|
||||
# Setup frontend
|
||||
("frontend", FRONTEND_INTEGRATIONS),
|
||||
# Setup recorder
|
||||
("recorder", RECORDER_INTEGRATIONS),
|
||||
# Start up debuggers. Start these first in case they want to wait.
|
||||
("debugger", DEBUGGER_INTEGRATIONS),
|
||||
)
|
||||
|
||||
#
|
||||
# Storage keys we are likely to load during startup
|
||||
# in order of when we expect to load them.
|
||||
@@ -694,7 +700,6 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
|
||||
return deps_dir
|
||||
|
||||
|
||||
@core.callback
|
||||
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||
"""Get domains of components to set up."""
|
||||
# Filter out the repeating and common config section [homeassistant]
|
||||
@@ -890,69 +895,48 @@ async def _async_set_up_integrations(
|
||||
domains_to_setup, integration_cache = await _async_resolve_domains_to_setup(
|
||||
hass, config
|
||||
)
|
||||
stage_2_domains = domains_to_setup.copy()
|
||||
|
||||
# Initialize recorder
|
||||
if "recorder" in domains_to_setup:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
|
||||
pre_stage_domains = [
|
||||
(name, domains_to_setup & domain_group) for name, domain_group in SETUP_ORDER
|
||||
stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [
|
||||
*(
|
||||
(name, domain_group & domains_to_setup, timeout)
|
||||
for name, domain_group, timeout in STAGE_0_INTEGRATIONS
|
||||
),
|
||||
("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT),
|
||||
]
|
||||
|
||||
# calculate what components to setup in what stage
|
||||
stage_1_domains: set[str] = set()
|
||||
_LOGGER.info("Setting up stage 0 and 1")
|
||||
for name, domain_group, timeout in stage_0_and_1_domains:
|
||||
if not domain_group:
|
||||
continue
|
||||
|
||||
# Find all dependencies of any dependency of any stage 1 integration that
|
||||
# we plan on loading and promote them to stage 1. This is done only to not
|
||||
# get misleading log messages
|
||||
deps_promotion: set[str] = STAGE_1_INTEGRATIONS
|
||||
while deps_promotion:
|
||||
old_deps_promotion = deps_promotion
|
||||
deps_promotion = set()
|
||||
_LOGGER.info("Setting up %s: %s", name, domain_group)
|
||||
to_be_loaded = domain_group.copy()
|
||||
to_be_loaded.update(
|
||||
dep
|
||||
for domain in domain_group
|
||||
if (integration := integration_cache.get(domain)) is not None
|
||||
for dep in integration.all_dependencies
|
||||
)
|
||||
async_set_domains_to_be_loaded(hass, to_be_loaded)
|
||||
stage_2_domains -= to_be_loaded
|
||||
|
||||
for domain in old_deps_promotion:
|
||||
if domain not in domains_to_setup or domain in stage_1_domains:
|
||||
continue
|
||||
|
||||
stage_1_domains.add(domain)
|
||||
|
||||
if (dep_itg := integration_cache.get(domain)) is None:
|
||||
continue
|
||||
|
||||
deps_promotion.update(dep_itg.all_dependencies)
|
||||
|
||||
stage_2_domains = domains_to_setup - stage_1_domains
|
||||
|
||||
for name, domain_group in pre_stage_domains:
|
||||
if domain_group:
|
||||
stage_2_domains -= domain_group
|
||||
_LOGGER.info("Setting up %s: %s", name, domain_group)
|
||||
to_be_loaded = domain_group.copy()
|
||||
to_be_loaded.update(
|
||||
dep
|
||||
for domain in domain_group
|
||||
if (integration := integration_cache.get(domain)) is not None
|
||||
for dep in integration.all_dependencies
|
||||
)
|
||||
async_set_domains_to_be_loaded(hass, to_be_loaded)
|
||||
if timeout is None:
|
||||
await _async_setup_multi_components(hass, domain_group, config)
|
||||
|
||||
# Enables after dependencies when setting up stage 1 domains
|
||||
async_set_domains_to_be_loaded(hass, stage_1_domains)
|
||||
|
||||
# Start setup
|
||||
if stage_1_domains:
|
||||
_LOGGER.info("Setting up stage 1: %s", stage_1_domains)
|
||||
try:
|
||||
async with hass.timeout.async_timeout(
|
||||
STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
|
||||
):
|
||||
await _async_setup_multi_components(hass, stage_1_domains, config)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Setup timed out for stage 1 waiting on %s - moving forward",
|
||||
hass._active_tasks, # noqa: SLF001
|
||||
)
|
||||
else:
|
||||
try:
|
||||
async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
|
||||
await _async_setup_multi_components(hass, domain_group, config)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Setup timed out for %s waiting on %s - moving forward",
|
||||
name,
|
||||
hass._active_tasks, # noqa: SLF001
|
||||
)
|
||||
|
||||
# Add after dependencies when setting up stage 2 domains
|
||||
async_set_domains_to_be_loaded(hass, stage_2_domains)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "sensorpush",
|
||||
"name": "SensorPush",
|
||||
"integrations": ["sensorpush", "sensorpush_cloud"]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@@ -123,12 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
|
||||
"""Unload AdGuard Home config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# This is the last loaded instance of AdGuard, deregister any services
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.9.1"],
|
||||
"requirements": ["airgradient==0.9.2"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(device_info["id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
_LOGGER.debug("Creating an entry for %s", device_info["name"])
|
||||
return self.async_create_entry(title=device_info["name"], data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aioairq import AirQ
|
||||
from aioairq.core import AirQ, identify_warming_up_sensors
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
@@ -55,6 +55,9 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch the data from the device."""
|
||||
if "name" not in self.device_info:
|
||||
_LOGGER.debug(
|
||||
"'name' not found in AirQCoordinator.device_info, fetching from the device"
|
||||
)
|
||||
info = await self.airq.fetch_device_info()
|
||||
self.device_info.update(
|
||||
DeviceInfo(
|
||||
@@ -64,7 +67,16 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
hw_version=info["hw_version"],
|
||||
)
|
||||
)
|
||||
return await self.airq.get_latest_data( # type: ignore[no-any-return]
|
||||
_LOGGER.debug(
|
||||
"Updated AirQCoordinator.device_info for 'name' %s",
|
||||
self.device_info.get("name"),
|
||||
)
|
||||
data: dict = await self.airq.get_latest_data(
|
||||
return_average=self.return_average,
|
||||
clip_negative_values=self.clip_negative,
|
||||
)
|
||||
if warming_up_sensors := identify_warming_up_sensors(data):
|
||||
_LOGGER.debug(
|
||||
"Following sensors are still warming up: %s", warming_up_sensors
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"alarm_arm_home": {
|
||||
"name": "Arm home",
|
||||
"description": "Sets the alarm to: _armed, but someone is home_.",
|
||||
"description": "Arms the alarm in the home mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
|
||||
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"alarm_arm_away": {
|
||||
"name": "Arm away",
|
||||
"description": "Sets the alarm to: _armed, no one home_.",
|
||||
"description": "Arms the alarm in the away mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
|
||||
@@ -110,7 +110,7 @@
|
||||
},
|
||||
"alarm_arm_night": {
|
||||
"name": "Arm night",
|
||||
"description": "Sets the alarm to: _armed for the night_.",
|
||||
"description": "Arms the alarm in the night mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"alarm_arm_vacation": {
|
||||
"name": "Arm vacation",
|
||||
"description": "Sets the alarm to: _armed for vacation_.",
|
||||
"description": "Arms the alarm in the vacation mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
|
||||
@@ -130,7 +130,7 @@
|
||||
},
|
||||
"alarm_trigger": {
|
||||
"name": "Trigger",
|
||||
"description": "Trigger the alarm manually.",
|
||||
"description": "Triggers the alarm manually.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
|
||||
|
||||
@@ -239,6 +239,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
native_unit_of_measurement=DEGREE,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDGUSTMPH,
|
||||
|
||||
@@ -608,21 +608,25 @@ SENSOR_DESCRIPTIONS = (
|
||||
key=TYPE_WINDDIR,
|
||||
translation_key="wind_direction",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR_AVG10M,
|
||||
translation_key="wind_direction_average_10m",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR_AVG2M,
|
||||
translation_key="wind_direction_average_2m",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDGUSTDIR,
|
||||
translation_key="wind_gust_direction",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDGUSTMPH,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"cannot_receive_deviceinfo": "Failed to retreive MAC Address. Make sure the device is turned on"
|
||||
"cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
"""Conversation support for Anthropic."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Any, Literal
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic._types import NOT_GIVEN
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
Message,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlock,
|
||||
@@ -109,7 +116,7 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam:
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
input=json.dumps(tool_call.tool_args),
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
for tool_call in chat_content.tool_calls or ()
|
||||
],
|
||||
@@ -124,6 +131,66 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam:
|
||||
raise ValueError(f"Unexpected content type: {type(chat_content)}")
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
result: AsyncStream[MessageStreamEvent],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty TextBlock
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawMessageDeltaEvent with a stop_reason='tool_use'
|
||||
- RawMessageStopEvent(type='message_stop')
|
||||
"""
|
||||
if result is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_tool_call: dict | None = None
|
||||
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_tool_call = {
|
||||
"id": response.content_block.id,
|
||||
"name": response.content_block.name,
|
||||
"input": "",
|
||||
}
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
yield {"role": "assistant"}
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
if current_tool_call is None:
|
||||
raise ValueError("Unexpected delta without a tool call")
|
||||
current_tool_call["input"] += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
LOGGER.debug("yielding delta: %s", response.delta.text)
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_tool_call:
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_call["id"],
|
||||
tool_name=current_tool_call["name"],
|
||||
tool_args=json.loads(current_tool_call["input"]),
|
||||
)
|
||||
]
|
||||
}
|
||||
current_tool_call = None
|
||||
|
||||
|
||||
class AnthropicConversationEntity(
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
@@ -206,58 +273,30 @@ class AnthropicConversationEntity(
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
try:
|
||||
response = await client.messages.create(
|
||||
stream = await client.messages.create(
|
||||
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
messages=messages,
|
||||
tools=tools or NOT_GIVEN,
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
system=system.content,
|
||||
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
stream=True,
|
||||
)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
) from err
|
||||
|
||||
LOGGER.debug("Response %s", response)
|
||||
|
||||
messages.append(_message_convert(response))
|
||||
|
||||
text = "".join(
|
||||
messages.extend(
|
||||
[
|
||||
content.text
|
||||
for content in response.content
|
||||
if isinstance(content, TextBlock)
|
||||
_convert_content(content)
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
user_input.agent_id, _transform_stream(stream)
|
||||
)
|
||||
]
|
||||
)
|
||||
tool_inputs = [
|
||||
llm.ToolInput(
|
||||
id=tool_call.id,
|
||||
tool_name=tool_call.name,
|
||||
tool_args=cast(dict[str, Any], tool_call.input),
|
||||
)
|
||||
for tool_call in response.content
|
||||
if isinstance(tool_call, ToolUseBlock)
|
||||
]
|
||||
|
||||
tool_results = [
|
||||
ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=tool_response.tool_call_id,
|
||||
content=json.dumps(tool_response.tool_result),
|
||||
)
|
||||
async for tool_response in chat_log.async_add_assistant_content(
|
||||
conversation.AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=text,
|
||||
tool_calls=tool_inputs or None,
|
||||
)
|
||||
)
|
||||
]
|
||||
if tool_results:
|
||||
messages.append(MessageParam(role="user", content=tool_results))
|
||||
|
||||
if not tool_inputs:
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
response_content = chat_log.content[-1]
|
||||
|
||||
@@ -19,10 +19,20 @@ class ApSystemsEntity(Entity):
|
||||
data: ApSystemsData,
|
||||
) -> None:
|
||||
"""Initialize the APsystems entity."""
|
||||
|
||||
# Handle device version safely
|
||||
sw_version = None
|
||||
if data.coordinator.device_version:
|
||||
version_parts = data.coordinator.device_version.split(" ")
|
||||
if len(version_parts) > 1:
|
||||
sw_version = version_parts[1]
|
||||
else:
|
||||
sw_version = version_parts[0]
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, data.device_id)},
|
||||
manufacturer="APsystems",
|
||||
model="EZ1-M",
|
||||
serial_number=data.device_id,
|
||||
sw_version=data.coordinator.device_version.split(" ")[1],
|
||||
sw_version=sw_version,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aranet4.client import Aranet4Advertisement
|
||||
from aranet4.client import Aranet4Advertisement, Color
|
||||
from bleak.backends.device import BLEDevice
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
@@ -74,6 +74,13 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=UnitOfPressure.HPA,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"status": AranetSensorEntityDescription(
|
||||
key="threshold",
|
||||
translation_key="threshold",
|
||||
name="Threshold",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[status.name.lower() for status in Color],
|
||||
),
|
||||
"co2": AranetSensorEntityDescription(
|
||||
key="co2",
|
||||
name="Carbon Dioxide",
|
||||
@@ -161,7 +168,10 @@ def sensor_update_to_bluetooth_data_update(
|
||||
val = getattr(adv.readings, key)
|
||||
if val == -1:
|
||||
continue
|
||||
val *= desc.scale
|
||||
if key == "status":
|
||||
val = val.name.lower()
|
||||
else:
|
||||
val *= desc.scale
|
||||
data[tag] = val
|
||||
names[tag] = desc.name
|
||||
descs[tag] = desc
|
||||
|
||||
@@ -21,5 +21,17 @@
|
||||
"no_devices_found": "No unconfigured Aranet devices found.",
|
||||
"outdated_version": "This device is using outdated firmware. Please update it to at least v1.2.0 and try again."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"threshold": {
|
||||
"state": {
|
||||
"error": "Error",
|
||||
"green": "Green",
|
||||
"yellow": "Yellow",
|
||||
"red": "Red"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["arcam"],
|
||||
"requirements": ["arcam-fmj==1.5.2"],
|
||||
"requirements": ["arcam-fmj==1.8.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -92,7 +92,12 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] |
|
||||
device_class=SensorDeviceClass.WIND_SPEED,
|
||||
),
|
||||
ArwnSensor(
|
||||
topic + "/dir", "Wind Direction", "direction", DEGREE, "mdi:compass"
|
||||
topic + "/dir",
|
||||
"Wind Direction",
|
||||
"direction",
|
||||
DEGREE,
|
||||
"mdi:compass",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
]
|
||||
return None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -405,7 +405,10 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
def _internal_on_pipeline_event(self, event: PipelineEvent) -> None:
|
||||
"""Set state based on pipeline stage."""
|
||||
if event.type is PipelineEventType.WAKE_WORD_START:
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
# Only return to idle if we're not currently responding.
|
||||
# The state will return to idle in tts_response_finished.
|
||||
if self.state != AssistSatelliteState.RESPONDING:
|
||||
self._set_state(AssistSatelliteState.IDLE)
|
||||
elif event.type is PipelineEventType.STT_START:
|
||||
self._set_state(AssistSatelliteState.LISTENING)
|
||||
elif event.type is PipelineEventType.INTENT_START:
|
||||
|
||||
@@ -19,6 +19,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
AssistSatelliteEntityFeature,
|
||||
)
|
||||
from .entity import AssistSatelliteConfiguration
|
||||
|
||||
CONNECTION_TEST_TIMEOUT = 30
|
||||
|
||||
@@ -91,7 +92,16 @@ def websocket_get_configuration(
|
||||
)
|
||||
return
|
||||
|
||||
config_dict = asdict(satellite.async_get_configuration())
|
||||
try:
|
||||
config_dict = asdict(satellite.async_get_configuration())
|
||||
except NotImplementedError:
|
||||
# Stub configuration
|
||||
config_dict = asdict(
|
||||
AssistSatelliteConfiguration(
|
||||
available_wake_words=[], active_wake_words=[], max_active_wake_words=1
|
||||
)
|
||||
)
|
||||
|
||||
config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id
|
||||
config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)",
|
||||
"invalid_serial_port": "Serial port is not a valid device or could not be openned",
|
||||
"invalid_serial_port": "Serial port is not a valid device or could not be opened",
|
||||
"cannot_open_serial_port": "Cannot open serial port, please check and try again"
|
||||
},
|
||||
"abort": {
|
||||
|
||||
@@ -16,6 +16,7 @@ from .agent import (
|
||||
BackupAgentPlatformProtocol,
|
||||
LocalBackupAgent,
|
||||
)
|
||||
from .config import BackupConfig, CreateBackupParametersDict
|
||||
from .const import DATA_MANAGER, DOMAIN
|
||||
from .http import async_register_http_views
|
||||
from .manager import (
|
||||
@@ -47,12 +48,14 @@ __all__ = [
|
||||
"BackupAgent",
|
||||
"BackupAgentError",
|
||||
"BackupAgentPlatformProtocol",
|
||||
"BackupConfig",
|
||||
"BackupManagerError",
|
||||
"BackupNotFound",
|
||||
"BackupPlatformProtocol",
|
||||
"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,
|
||||
|
||||
@@ -43,7 +43,11 @@ from .agent import (
|
||||
BackupAgentPlatformProtocol,
|
||||
LocalBackupAgent,
|
||||
)
|
||||
from .config import BackupConfig, delete_backups_exceeding_configured_count
|
||||
from .config import (
|
||||
BackupConfig,
|
||||
CreateBackupParametersDict,
|
||||
delete_backups_exceeding_configured_count,
|
||||
)
|
||||
from .const import (
|
||||
BUF_SIZE,
|
||||
DATA_MANAGER,
|
||||
@@ -282,6 +286,10 @@ class BackupReaderWriter(abc.ABC):
|
||||
) -> None:
|
||||
"""Get restore events after core restart."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def async_validate_config(self, *, config: BackupConfig) -> None:
|
||||
"""Validate backup config."""
|
||||
|
||||
|
||||
class IncorrectPasswordError(BackupReaderWriterError):
|
||||
"""Raised when the password is incorrect."""
|
||||
@@ -333,6 +341,7 @@ class BackupManager:
|
||||
self.config.load(stored["config"])
|
||||
self.known_backups.load(stored["backups"])
|
||||
|
||||
await self._reader_writer.async_validate_config(config=self.config)
|
||||
await self._reader_writer.async_resume_restore_progress_after_restart(
|
||||
on_progress=self.async_on_backup_event
|
||||
)
|
||||
@@ -1832,6 +1841,44 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
)
|
||||
on_progress(IdleEvent())
|
||||
|
||||
async def async_validate_config(self, *, config: BackupConfig) -> None:
|
||||
"""Validate backup config.
|
||||
|
||||
Update automatic backup settings to not include addons or folders and remove
|
||||
hassio agents in case a backup created by supervisor was restored.
|
||||
"""
|
||||
create_backup = config.data.create_backup
|
||||
if (
|
||||
not create_backup.include_addons
|
||||
and not create_backup.include_all_addons
|
||||
and not create_backup.include_folders
|
||||
and not any(a_id.startswith("hassio.") for a_id in create_backup.agent_ids)
|
||||
):
|
||||
LOGGER.debug("Backup settings don't need to be adjusted")
|
||||
return
|
||||
|
||||
LOGGER.info(
|
||||
"Adjusting backup settings to not include addons, folders or supervisor locations"
|
||||
)
|
||||
automatic_agents = [
|
||||
agent_id
|
||||
for agent_id in create_backup.agent_ids
|
||||
if not agent_id.startswith("hassio.")
|
||||
]
|
||||
if (
|
||||
self._local_agent_id not in automatic_agents
|
||||
and "hassio.local" in create_backup.agent_ids
|
||||
):
|
||||
automatic_agents = [self._local_agent_id, *automatic_agents]
|
||||
config.update(
|
||||
create_backup=CreateBackupParametersDict(
|
||||
agent_ids=automatic_agents,
|
||||
include_addons=None,
|
||||
include_all_addons=False,
|
||||
include_folders=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _generate_backup_id(date: str, name: str) -> str:
|
||||
"""Generate a backup ID."""
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 3
|
||||
STORAGE_VERSION_MINOR = 4
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -60,6 +60,13 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
else:
|
||||
data["config"]["schedule"]["days"] = [state]
|
||||
data["config"]["schedule"]["recurrence"] = "custom_days"
|
||||
if old_minor_version < 4:
|
||||
# Workaround for a bug in frontend which incorrectly set days to 0
|
||||
# instead of to None for unlimited retention.
|
||||
if data["config"]["retention"]["copies"] == 0:
|
||||
data["config"]["retention"]["copies"] = None
|
||||
if data["config"]["retention"]["days"] == 0:
|
||||
data["config"]["retention"]["days"] = None
|
||||
|
||||
# Note: We allow reading data with major version 2.
|
||||
# Reject if major version is higher than 2.
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
@@ -368,8 +369,10 @@ async def handle_config_info(
|
||||
),
|
||||
vol.Optional("retention"): vol.Schema(
|
||||
{
|
||||
vol.Optional("copies"): vol.Any(int, None),
|
||||
vol.Optional("days"): vol.Any(int, None),
|
||||
# Note: We can't use cv.positive_int because it allows 0 even
|
||||
# though 0 is not positive.
|
||||
vol.Optional("copies"): vol.Any(vol.All(int, vol.Range(min=1)), None),
|
||||
vol.Optional("days"): vol.Any(vol.All(int, vol.Range(min=1)), None),
|
||||
},
|
||||
),
|
||||
vol.Optional("schedule"): vol.Schema(
|
||||
@@ -385,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],
|
||||
@@ -396,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"])
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ PLATFORMS = [
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/balboa",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pybalboa"],
|
||||
"requirements": ["pybalboa==1.1.2"]
|
||||
"requirements": ["pybalboa==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -78,6 +78,19 @@
|
||||
"high": "High"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"filter_cycle_2_enabled": {
|
||||
"name": "Filter cycle 2 enabled"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"filter_cycle_start": {
|
||||
"name": "Filter cycle {index} start"
|
||||
},
|
||||
"filter_cycle_end": {
|
||||
"name": "Filter cycle {index} end"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Support for Balboa switches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pybalboa import SpaClient
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BalboaConfigEntry
|
||||
from .entity import BalboaEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: BalboaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the spa's switches."""
|
||||
spa = entry.runtime_data
|
||||
async_add_entities([BalboaSwitchEntity(spa)])
|
||||
|
||||
|
||||
class BalboaSwitchEntity(BalboaEntity, SwitchEntity):
|
||||
"""Representation of a Balboa switch entity."""
|
||||
|
||||
def __init__(self, spa: SpaClient) -> None:
|
||||
"""Initialize a Balboa switch entity."""
|
||||
super().__init__(spa, "filter_cycle_2_enabled")
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
self._attr_translation_key = "filter_cycle_2_enabled"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if entity is on."""
|
||||
return self._client.filter_cycle_2_enabled
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self._client.configure_filter_cycle(2, enabled=True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self._client.configure_filter_cycle(2, enabled=False)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Support for Balboa times."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time
|
||||
import itertools
|
||||
from typing import Any
|
||||
|
||||
from pybalboa import SpaClient
|
||||
|
||||
from homeassistant.components.time import TimeEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BalboaConfigEntry
|
||||
from .entity import BalboaEntity
|
||||
|
||||
FILTER_CYCLE = "filter_cycle_"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: BalboaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the spa's times."""
|
||||
spa = entry.runtime_data
|
||||
async_add_entities(
|
||||
BalboaTimeEntity(spa, index, period)
|
||||
for index, period in itertools.product((1, 2), ("start", "end"))
|
||||
)
|
||||
|
||||
|
||||
class BalboaTimeEntity(BalboaEntity, TimeEntity):
|
||||
"""Representation of a Balboa time entity."""
|
||||
|
||||
entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self, spa: SpaClient, index: int, period: str) -> None:
|
||||
"""Initialize a Balboa time entity."""
|
||||
super().__init__(spa, f"{FILTER_CYCLE}{index}_{period}")
|
||||
self.index = index
|
||||
self.period = period
|
||||
self._attr_translation_key = f"{FILTER_CYCLE}{period}"
|
||||
self._attr_translation_placeholders = {"index": str(index)}
|
||||
|
||||
@property
|
||||
def native_value(self) -> time | None:
|
||||
"""Return the value reported by the time."""
|
||||
return getattr(self._client, f"{FILTER_CYCLE}{self.index}_{self.period}")
|
||||
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Change the time."""
|
||||
args: dict[str, Any] = {self.period: value}
|
||||
await self._client.configure_filter_cycle(self.index, **args)
|
||||
@@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .const import DEVICE_BUTTONS, DOMAIN
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -25,8 +26,9 @@ async def async_get_config_entry_diagnostics(
|
||||
if TYPE_CHECKING:
|
||||
assert config_entry.unique_id
|
||||
|
||||
# Add media_player entity's state
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
# Add media_player entity's state
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id
|
||||
):
|
||||
@@ -37,4 +39,16 @@ async def async_get_config_entry_diagnostics(
|
||||
state_dict.pop("context")
|
||||
data["media_player"] = state_dict
|
||||
|
||||
# Add button Event entity states (if enabled)
|
||||
for device_button in DEVICE_BUTTONS:
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}"
|
||||
):
|
||||
if state := hass.states.get(entity_id):
|
||||
state_dict = dict(state.as_dict())
|
||||
|
||||
# Remove context as it is not relevant
|
||||
state_dict.pop("context")
|
||||
data[f"{device_button}_event"] = state_dict
|
||||
|
||||
return data
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
"title": "Manual YAML fix required for Bayesian"
|
||||
},
|
||||
"no_prob_given_false": {
|
||||
"description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.",
|
||||
"description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yaml` for `bayesian/{entity}`. These observations will be ignored until you do.",
|
||||
"title": "Manual YAML addition required for Bayesian"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"name": "[%key:common::action::reload%]",
|
||||
"description": "Reloads bayesian sensors from the YAML-configuration."
|
||||
"description": "Reloads Bayesian sensors from the YAML-configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.21.1"
|
||||
"habluetooth==3.22.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,6 +91,13 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = (
|
||||
mutually_exclusive=Action.SET_BRIGHTNESS,
|
||||
argument=None,
|
||||
),
|
||||
BondButtonEntityDescription(
|
||||
key=Action.TOGGLE_LIGHT_TEMP,
|
||||
name="Toggle Light Temperature",
|
||||
translation_key="toggle_light_temp",
|
||||
mutually_exclusive=None, # No mutually exclusive action
|
||||
argument=None,
|
||||
),
|
||||
BondButtonEntityDescription(
|
||||
key=Action.START_UP_LIGHT_DIMMER,
|
||||
name="Start Up Light Dimmer",
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bring_api"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bring-api==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ rules:
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: todo
|
||||
docs-installation-instructions: todo
|
||||
docs-removal-instructions: todo
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: The integration registers no events
|
||||
@@ -26,8 +26,10 @@ rules:
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration has no configuration parameters
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
@@ -46,13 +48,15 @@ rules:
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration is a service and has no devices.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: Integration is a service and has no devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -17,13 +17,13 @@ class BroadlinkEntity(Entity):
|
||||
self._device = device
|
||||
self._coordinator = device.update_manager.coordinator
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when the entity is added to hass."""
|
||||
self.async_on_remove(self._coordinator.async_add_listener(self._recv_data))
|
||||
if self._coordinator.data:
|
||||
self._update_state(self._coordinator.data)
|
||||
|
||||
async def async_update(self):
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self._coordinator.async_request_refresh()
|
||||
|
||||
@@ -49,7 +49,7 @@ class BroadlinkEntity(Entity):
|
||||
"""
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return True if the entity is available."""
|
||||
return self._device.available
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
|
||||
},
|
||||
"set_data_error": {
|
||||
"message": "An error occurred while sending the data to the BSBLAN device"
|
||||
"message": "An error occurred while sending the data to the BSB-Lan device"
|
||||
},
|
||||
"set_temperature_error": {
|
||||
"message": "An error occurred while setting the temperature"
|
||||
|
||||
@@ -169,6 +169,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
translation_key="windazimuth",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="pressure",
|
||||
@@ -530,30 +531,35 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
translation_key="windazimuth_1d",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="windazimuth_2d",
|
||||
translation_key="windazimuth_2d",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="windazimuth_3d",
|
||||
translation_key="windazimuth_3d",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="windazimuth_4d",
|
||||
translation_key="windazimuth_4d",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="windazimuth_5d",
|
||||
translation_key="windazimuth_5d",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="condition_1d",
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from hass_nabucasa import Cloud
|
||||
@@ -19,6 +20,7 @@ from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_REGION,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
FORMAT_DATETIME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
|
||||
@@ -33,7 +35,7 @@ from homeassistant.helpers.dispatcher import (
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.loader import async_get_integration, bind_hass
|
||||
from homeassistant.util.signal_type import SignalType
|
||||
|
||||
# Pre-import backup to avoid it being imported
|
||||
@@ -62,11 +64,13 @@ from .const import (
|
||||
CONF_THINGTALK_SERVER,
|
||||
CONF_USER_POOL_ID,
|
||||
DATA_CLOUD,
|
||||
DATA_CLOUD_LOG_HANDLER,
|
||||
DATA_PLATFORMS_SETUP,
|
||||
DOMAIN,
|
||||
MODE_DEV,
|
||||
MODE_PROD,
|
||||
)
|
||||
from .helpers import FixedSizeQueueLogHandler
|
||||
from .prefs import CloudPreferences
|
||||
from .repairs import async_manage_legacy_subscription_issue
|
||||
from .subscription import async_subscription_info
|
||||
@@ -245,6 +249,8 @@ def async_remote_ui_url(hass: HomeAssistant) -> str:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Initialize the Home Assistant cloud."""
|
||||
log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] = await _setup_log_handler(hass)
|
||||
|
||||
# Process configs
|
||||
if DOMAIN in config:
|
||||
kwargs = dict(config[DOMAIN])
|
||||
@@ -267,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def _shutdown(event: Event) -> None:
|
||||
"""Shutdown event."""
|
||||
await cloud.stop()
|
||||
logging.root.removeHandler(log_handler)
|
||||
del hass.data[DATA_CLOUD_LOG_HANDLER]
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||
|
||||
@@ -405,3 +413,19 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
|
||||
async_register_admin_service(
|
||||
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
|
||||
)
|
||||
|
||||
|
||||
async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler:
|
||||
fmt = (
|
||||
"%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s"
|
||||
)
|
||||
handler = FixedSizeQueueLogHandler()
|
||||
handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
|
||||
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
loggers: set[str] = {integration.pkg_path, *(integration.loggers or [])}
|
||||
|
||||
for logger_name in loggers:
|
||||
logging.getLogger(logger_name).addHandler(handler)
|
||||
|
||||
return handler
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from hass_nabucasa import Cloud, CloudError
|
||||
from hass_nabucasa.api import CloudApiNonRetryableError
|
||||
from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list
|
||||
from hass_nabucasa.cloud_api import (
|
||||
FilesHandlerListEntry,
|
||||
async_files_delete_file,
|
||||
async_files_list,
|
||||
)
|
||||
from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
|
||||
|
||||
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -24,20 +27,11 @@ from .client import CloudClient
|
||||
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_STORAGE_BACKUP: Literal["backup"] = "backup"
|
||||
_RETRY_LIMIT = 5
|
||||
_RETRY_SECONDS_MIN = 60
|
||||
_RETRY_SECONDS_MAX = 600
|
||||
|
||||
|
||||
async def _b64md5(stream: AsyncIterator[bytes]) -> str:
|
||||
"""Calculate the MD5 hash of a file."""
|
||||
file_hash = hashlib.md5()
|
||||
async for chunk in stream:
|
||||
file_hash.update(chunk)
|
||||
return base64.b64encode(file_hash.digest()).decode()
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
**kwargs: Any,
|
||||
@@ -86,11 +80,6 @@ class CloudBackupAgent(BackupAgent):
|
||||
self._cloud = cloud
|
||||
self._hass = hass
|
||||
|
||||
@callback
|
||||
def _get_backup_filename(self) -> str:
|
||||
"""Return the backup filename."""
|
||||
return f"{self._cloud.client.prefs.instance_id}.tar"
|
||||
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
@@ -101,13 +90,13 @@ class CloudBackupAgent(BackupAgent):
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
:return: An async iterator that yields bytes.
|
||||
"""
|
||||
if not await self.async_get_backup(backup_id):
|
||||
if not (backup := await self._async_get_backup(backup_id)):
|
||||
raise BackupAgentError("Backup not found")
|
||||
|
||||
try:
|
||||
content = await self._cloud.files.download(
|
||||
storage_type=_STORAGE_BACKUP,
|
||||
filename=self._get_backup_filename(),
|
||||
storage_type=StorageType.BACKUP,
|
||||
filename=backup["Key"],
|
||||
)
|
||||
except CloudError as err:
|
||||
raise BackupAgentError(f"Failed to download backup: {err}") from err
|
||||
@@ -129,16 +118,19 @@ class CloudBackupAgent(BackupAgent):
|
||||
if not backup.protected:
|
||||
raise BackupAgentError("Cloud backups must be protected")
|
||||
|
||||
base64md5hash = await _b64md5(await open_stream())
|
||||
filename = self._get_backup_filename()
|
||||
metadata = backup.as_dict()
|
||||
size = backup.size
|
||||
try:
|
||||
base64md5hash = await calculate_b64md5(open_stream, size)
|
||||
except FilesError as err:
|
||||
raise BackupAgentError(err) from err
|
||||
filename = f"{self._cloud.client.prefs.instance_id}.tar"
|
||||
metadata = backup.as_dict()
|
||||
|
||||
tries = 1
|
||||
while tries <= _RETRY_LIMIT:
|
||||
try:
|
||||
await self._cloud.files.upload(
|
||||
storage_type=_STORAGE_BACKUP,
|
||||
storage_type=StorageType.BACKUP,
|
||||
open_stream=open_stream,
|
||||
filename=filename,
|
||||
base64md5hash=base64md5hash,
|
||||
@@ -179,27 +171,34 @@ class CloudBackupAgent(BackupAgent):
|
||||
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
"""
|
||||
if not await self.async_get_backup(backup_id):
|
||||
if not (backup := await self._async_get_backup(backup_id)):
|
||||
return
|
||||
|
||||
try:
|
||||
await async_files_delete_file(
|
||||
self._cloud,
|
||||
storage_type=_STORAGE_BACKUP,
|
||||
filename=self._get_backup_filename(),
|
||||
storage_type=StorageType.BACKUP,
|
||||
filename=backup["Key"],
|
||||
)
|
||||
except (ClientError, CloudError) as err:
|
||||
raise BackupAgentError("Failed to delete backup") from err
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
backups = await self._async_list_backups()
|
||||
return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
|
||||
|
||||
async def _async_list_backups(self) -> list[FilesHandlerListEntry]:
|
||||
"""List backups."""
|
||||
try:
|
||||
backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP)
|
||||
_LOGGER.debug("Cloud backups: %s", backups)
|
||||
backups = await async_files_list(
|
||||
self._cloud, storage_type=StorageType.BACKUP
|
||||
)
|
||||
except (ClientError, CloudError) as err:
|
||||
raise BackupAgentError("Failed to list backups") from err
|
||||
|
||||
return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
|
||||
_LOGGER.debug("Cloud backups: %s", backups)
|
||||
return backups
|
||||
|
||||
async def async_get_backup(
|
||||
self,
|
||||
@@ -207,10 +206,19 @@ class CloudBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
backups = await self.async_list_backups()
|
||||
if not (backup := await self._async_get_backup(backup_id)):
|
||||
return None
|
||||
return AgentBackup.from_dict(backup["Metadata"])
|
||||
|
||||
async def _async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
) -> FilesHandlerListEntry | None:
|
||||
"""Return a backup."""
|
||||
backups = await self._async_list_backups()
|
||||
|
||||
for backup in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
if backup["Metadata"]["backup_id"] == backup_id:
|
||||
return backup
|
||||
|
||||
return None
|
||||
|
||||
@@ -12,12 +12,14 @@ if TYPE_CHECKING:
|
||||
from hass_nabucasa import Cloud
|
||||
|
||||
from .client import CloudClient
|
||||
from .helpers import FixedSizeQueueLogHandler
|
||||
|
||||
DOMAIN = "cloud"
|
||||
DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN)
|
||||
DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey(
|
||||
"cloud_platforms_setup"
|
||||
)
|
||||
DATA_CLOUD_LOG_HANDLER: HassKey[FixedSizeQueueLogHandler] = HassKey("cloud_log_handler")
|
||||
EVENT_CLOUD_EVENT = "cloud_event"
|
||||
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Helpers for the cloud component."""
|
||||
|
||||
from collections import deque
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
class FixedSizeQueueLogHandler(logging.Handler):
|
||||
"""Log handler to store messages, with auto rotation."""
|
||||
|
||||
MAX_RECORDS = 500
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new LogHandler."""
|
||||
super().__init__()
|
||||
self._records: deque[logging.LogRecord] = deque(maxlen=self.MAX_RECORDS)
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Store log message."""
|
||||
self._records.append(record)
|
||||
|
||||
async def get_logs(self, hass: HomeAssistant) -> list[str]:
|
||||
"""Get stored logs."""
|
||||
|
||||
def _get_logs() -> list[str]:
|
||||
# copy the queue since it can mutate while iterating
|
||||
records = self._records.copy()
|
||||
return [self.format(record) for record in records]
|
||||
|
||||
return await hass.async_add_executor_job(_get_logs)
|
||||
@@ -8,14 +8,15 @@ from contextlib import suppress
|
||||
import dataclasses
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Concatenate
|
||||
from typing import Any, Concatenate, cast
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
import attr
|
||||
from hass_nabucasa import Cloud, auth, thingtalk
|
||||
from hass_nabucasa import AlreadyConnectedError, Cloud, auth, thingtalk
|
||||
from hass_nabucasa.const import STATE_DISCONNECTED
|
||||
from hass_nabucasa.voice import TTS_VOICES
|
||||
import voluptuous as vol
|
||||
@@ -43,6 +44,7 @@ from .assist_pipeline import async_create_cloud_pipeline
|
||||
from .client import CloudClient
|
||||
from .const import (
|
||||
DATA_CLOUD,
|
||||
DATA_CLOUD_LOG_HANDLER,
|
||||
EVENT_CLOUD_EVENT,
|
||||
LOGIN_MFA_TIMEOUT,
|
||||
PREF_ALEXA_REPORT_STATE,
|
||||
@@ -63,7 +65,9 @@ from .subscription import async_subscription_info
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = {
|
||||
_CLOUD_ERRORS: dict[
|
||||
type[Exception], tuple[HTTPStatus, Callable[[Exception], str] | str]
|
||||
] = {
|
||||
TimeoutError: (
|
||||
HTTPStatus.BAD_GATEWAY,
|
||||
"Unable to reach the Home Assistant cloud.",
|
||||
@@ -132,6 +136,10 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Multi-factor authentication expired, or not started. Please try again.",
|
||||
),
|
||||
AlreadyConnectedError: (
|
||||
HTTPStatus.CONFLICT,
|
||||
lambda x: json.dumps(cast(AlreadyConnectedError, x).details),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -196,7 +204,11 @@ def _process_cloud_exception(exc: Exception, where: str) -> tuple[HTTPStatus, st
|
||||
|
||||
for err, value_info in _CLOUD_ERRORS.items():
|
||||
if isinstance(exc, err):
|
||||
err_info = value_info
|
||||
status, content = value_info
|
||||
err_info = (
|
||||
status,
|
||||
content if isinstance(content, str) else content(exc),
|
||||
)
|
||||
break
|
||||
|
||||
if err_info is None:
|
||||
@@ -239,6 +251,7 @@ class CloudLoginView(HomeAssistantView):
|
||||
vol.All(
|
||||
{
|
||||
vol.Required("email"): str,
|
||||
vol.Optional("check_connection", default=False): bool,
|
||||
vol.Exclusive("password", "login"): str,
|
||||
vol.Exclusive("code", "login"): str,
|
||||
},
|
||||
@@ -257,7 +270,11 @@ class CloudLoginView(HomeAssistantView):
|
||||
code = data.get("code")
|
||||
|
||||
if email and password:
|
||||
await cloud.login(email, password)
|
||||
await cloud.login(
|
||||
email,
|
||||
password,
|
||||
check_connection=data["check_connection"],
|
||||
)
|
||||
|
||||
else:
|
||||
if (
|
||||
@@ -269,7 +286,12 @@ class CloudLoginView(HomeAssistantView):
|
||||
# Voluptuous should ensure that code is not None because password is
|
||||
assert code is not None
|
||||
|
||||
await cloud.login_verify_totp(email, code, self._mfa_tokens)
|
||||
await cloud.login_verify_totp(
|
||||
email,
|
||||
code,
|
||||
self._mfa_tokens,
|
||||
check_connection=data["check_connection"],
|
||||
)
|
||||
self._mfa_tokens = {}
|
||||
self._mfa_tokens_set_time = 0
|
||||
|
||||
@@ -397,8 +419,11 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
url = "/api/cloud/support_package"
|
||||
name = "api:cloud:support_package"
|
||||
|
||||
def _generate_markdown(
|
||||
self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
hass_info: dict[str, Any],
|
||||
domains_info: dict[str, dict[str, str]],
|
||||
) -> str:
|
||||
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
|
||||
if len(domain_info) == 0:
|
||||
@@ -424,6 +449,17 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"</details>\n\n"
|
||||
)
|
||||
|
||||
log_handler = hass.data[DATA_CLOUD_LOG_HANDLER]
|
||||
logs = "\n".join(await log_handler.get_logs(hass))
|
||||
markdown += (
|
||||
"## Full logs\n\n"
|
||||
"<details><summary>Logs</summary>\n\n"
|
||||
"```logs\n"
|
||||
f"{logs}\n"
|
||||
"```\n\n"
|
||||
"</details>\n"
|
||||
)
|
||||
|
||||
return markdown
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
@@ -433,7 +469,7 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
domain_health = await get_system_health_info(hass)
|
||||
|
||||
hass_info = domain_health.pop("homeassistant", {})
|
||||
markdown = self._generate_markdown(hass_info, domain_health)
|
||||
markdown = await self._generate_markdown(hass, hass_info, domain_health)
|
||||
|
||||
return web.Response(
|
||||
body=markdown,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloud",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["hass_nabucasa"],
|
||||
"requirements": ["hass-nabucasa==0.89.0"],
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.92.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -53,6 +53,7 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_track_state_added_domain
|
||||
from homeassistant.util import language as language_util
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .chat_log import AssistantContent, async_get_chat_log
|
||||
@@ -184,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],
|
||||
@@ -914,26 +900,20 @@ class DefaultAgent(ConversationEntity):
|
||||
def _load_intents(self, language: str) -> LanguageIntents | None:
|
||||
"""Load all intents for language (run inside executor)."""
|
||||
intents_dict: dict[str, Any] = {}
|
||||
language_variant: str | None = None
|
||||
supported_langs = set(get_languages())
|
||||
|
||||
# Choose a language variant upfront and commit to it for custom
|
||||
# sentences, etc.
|
||||
all_language_variants = {lang.lower(): lang for lang in supported_langs}
|
||||
lang_matches = language_util.matches(language, supported_langs)
|
||||
|
||||
# en-US, en_US, en, ...
|
||||
for maybe_variant in _get_language_variations(language):
|
||||
matching_variant = all_language_variants.get(maybe_variant.lower())
|
||||
if matching_variant:
|
||||
language_variant = matching_variant
|
||||
break
|
||||
|
||||
if not language_variant:
|
||||
if not lang_matches:
|
||||
_LOGGER.warning(
|
||||
"Unable to find supported language variant for %s", language
|
||||
)
|
||||
return None
|
||||
|
||||
language_variant = lang_matches[0]
|
||||
|
||||
# Load intents for this language variant
|
||||
lang_variant_intents = get_intents(language_variant, json_load=json_load)
|
||||
|
||||
@@ -1329,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.
|
||||
|
||||
@@ -1336,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
|
||||
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"discovery_error": "Failed to discover a Denon AVR Network Receiver"
|
||||
"discovery_error": "Failed to discover a Denon AVR network receiver"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "Failed to connect, please try again, disconnecting mains power and ethernet cables and reconnecting them may help",
|
||||
"not_denonavr_manufacturer": "Not a Denon AVR Network Receiver, discovered manufacturer did not match",
|
||||
"not_denonavr_missing": "Not a Denon AVR Network Receiver, discovery information not complete"
|
||||
"cannot_connect": "Failed to connect, please try again, disconnecting mains power and Ethernet cables and reconnecting them may help",
|
||||
"not_denonavr_manufacturer": "Not a Denon AVR network receiver, discovered manufacturer did not match",
|
||||
"not_denonavr_missing": "Not a Denon AVR network receiver, discovery information not complete"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -64,7 +64,7 @@
|
||||
"fields": {
|
||||
"dynamic_eq": {
|
||||
"name": "Dynamic equalizer",
|
||||
"description": "True/false for enable/disable."
|
||||
"description": "Whether DynamicEQ should be enabled or disabled."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@ class DSMRConnection:
|
||||
self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "5B":
|
||||
self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "5L":
|
||||
if dsmr_version in ("5L", "5EONHU"):
|
||||
self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER
|
||||
if dsmr_version == "Q3D":
|
||||
self._equipment_identifier = obis_ref.Q3D_EQUIPMENT_IDENTIFIER
|
||||
|
||||
@@ -28,7 +28,7 @@ DEVICE_NAME_GAS = "Gas Meter"
|
||||
DEVICE_NAME_WATER = "Water Meter"
|
||||
DEVICE_NAME_HEAT = "Heat Meter"
|
||||
|
||||
DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"}
|
||||
DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D", "5EONHU"}
|
||||
|
||||
DSMR_PROTOCOL = "dsmr_protocol"
|
||||
RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol"
|
||||
|
||||
@@ -115,7 +115,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
key="electricity_active_tariff",
|
||||
translation_key="electricity_active_tariff",
|
||||
obis_reference="ELECTRICITY_ACTIVE_TARIFF",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=["low", "normal"],
|
||||
),
|
||||
@@ -123,7 +123,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
key="electricity_used_tariff_1",
|
||||
translation_key="electricity_used_tariff_1",
|
||||
obis_reference="ELECTRICITY_USED_TARIFF_1",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -131,7 +131,25 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
key="electricity_used_tariff_2",
|
||||
translation_key="electricity_used_tariff_2",
|
||||
obis_reference="ELECTRICITY_USED_TARIFF_2",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_used_tariff_3",
|
||||
translation_key="electricity_used_tariff_3",
|
||||
obis_reference="ELECTRICITY_USED_TARIFF_3",
|
||||
dsmr_versions={"5EONHU"},
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_used_tariff_4",
|
||||
translation_key="electricity_used_tariff_4",
|
||||
obis_reference="ELECTRICITY_USED_TARIFF_4",
|
||||
dsmr_versions={"5EONHU"},
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -139,7 +157,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
key="electricity_delivered_tariff_1",
|
||||
translation_key="electricity_delivered_tariff_1",
|
||||
obis_reference="ELECTRICITY_DELIVERED_TARIFF_1",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -147,7 +165,25 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
key="electricity_delivered_tariff_2",
|
||||
translation_key="electricity_delivered_tariff_2",
|
||||
obis_reference="ELECTRICITY_DELIVERED_TARIFF_2",
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L"},
|
||||
dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_delivered_tariff_3",
|
||||
translation_key="electricity_delivered_tariff_3",
|
||||
obis_reference="ELECTRICITY_DELIVERED_TARIFF_3",
|
||||
dsmr_versions={"5EONHU"},
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="electricity_delivered_tariff_4",
|
||||
translation_key="electricity_delivered_tariff_4",
|
||||
obis_reference="ELECTRICITY_DELIVERED_TARIFF_4",
|
||||
dsmr_versions={"5EONHU"},
|
||||
force_update=True,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -341,7 +377,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
key="electricity_imported_total",
|
||||
translation_key="electricity_imported_total",
|
||||
obis_reference="ELECTRICITY_IMPORTED_TOTAL",
|
||||
dsmr_versions={"5L", "5S", "Q3D"},
|
||||
dsmr_versions={"5L", "5S", "Q3D", "5EONHU"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -349,7 +385,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
key="electricity_exported_total",
|
||||
translation_key="electricity_exported_total",
|
||||
obis_reference="ELECTRICITY_EXPORTED_TOTAL",
|
||||
dsmr_versions={"5L", "5S", "Q3D"},
|
||||
dsmr_versions={"5L", "5S", "Q3D", "5EONHU"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
@@ -387,6 +423,113 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.GAS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="actual_threshold_electricity",
|
||||
translation_key="actual_threshold_electricity",
|
||||
obis_reference="ACTUAL_TRESHOLD_ELECTRICITY", # Misspelled in external tool
|
||||
dsmr_versions={"5EONHU"},
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="eon_hu_electricity_combined",
|
||||
translation_key="electricity_combined",
|
||||
obis_reference="EON_HU_ELECTRICITY_COMBINED",
|
||||
dsmr_versions={"5EONHU"},
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="eon_hu_instantaneous_power_factor_total",
|
||||
translation_key="instantaneous_power_factor_total",
|
||||
obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_TOTAL",
|
||||
dsmr_versions={"5EONHU"},
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="eon_hu_instantaneous_power_factor_l1",
|
||||
translation_key="instantaneous_power_factor_l1",
|
||||
obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L1",
|
||||
dsmr_versions={"5EONHU"},
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="eon_hu_instantaneous_power_factor_l2",
|
||||
translation_key="instantaneous_power_factor_l2",
|
||||
obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L2",
|
||||
dsmr_versions={"5EONHU"},
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="eon_hu_instantaneous_power_factor_l3",
|
||||
translation_key="instantaneous_power_factor_l3",
|
||||
obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L3",
|
||||
dsmr_versions={"5EONHU"},
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="eon_hu_frequency",
|
||||
translation_key="frequency",
|
||||
obis_reference="EON_HU_FREQUENCY",
|
||||
dsmr_versions={"5EONHU"},
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="fuse_threshold_l1",
|
||||
translation_key="fuse_threshold_l1",
|
||||
obis_reference="FUSE_THRESHOLD_L1",
|
||||
dsmr_versions={"5EONHU"},
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="fuse_threshold_l2",
|
||||
translation_key="fuse_threshold_l2",
|
||||
obis_reference="FUSE_THRESHOLD_L2",
|
||||
dsmr_versions={"5EONHU"},
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="fuse_threshold_l3",
|
||||
translation_key="fuse_threshold_l3",
|
||||
obis_reference="FUSE_THRESHOLD_L3",
|
||||
dsmr_versions={"5EONHU"},
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
key="text_message",
|
||||
translation_key="text_message",
|
||||
obis_reference="TEXT_MESSAGE",
|
||||
dsmr_versions={"5EONHU"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = {
|
||||
@@ -844,8 +987,9 @@ class DSMREntity(SensorEntity):
|
||||
def translate_tariff(value: str, dsmr_version: str) -> str | None:
|
||||
"""Convert 2/1 to normal/low depending on DSMR version."""
|
||||
# DSMR V5B: Note: In Belgium values are swapped:
|
||||
# DSMR V5EONHU: Note: In EON HUngary values are swapped:
|
||||
# Rate code 2 is used for low rate and rate code 1 is used for normal rate.
|
||||
if dsmr_version == "5B":
|
||||
if dsmr_version in ("5B", "5EONHU"):
|
||||
if value == "0001":
|
||||
value = "0002"
|
||||
elif value == "0002":
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
"electricity_delivered_tariff_2": {
|
||||
"name": "Energy production (tarif 2)"
|
||||
},
|
||||
"electricity_delivered_tariff_3": {
|
||||
"name": "Energy production (tarif 3)"
|
||||
},
|
||||
"electricity_delivered_tariff_4": {
|
||||
"name": "Energy production (tarif 4)"
|
||||
},
|
||||
"electricity_exported_total": {
|
||||
"name": "Energy production (total)"
|
||||
},
|
||||
@@ -73,6 +79,12 @@
|
||||
"electricity_used_tariff_2": {
|
||||
"name": "Energy consumption (tarif 2)"
|
||||
},
|
||||
"electricity_used_tariff_3": {
|
||||
"name": "Energy consumption (tarif 3)"
|
||||
},
|
||||
"electricity_used_tariff_4": {
|
||||
"name": "Energy consumption (tarif 4)"
|
||||
},
|
||||
"gas_meter_reading": {
|
||||
"name": "Gas consumption"
|
||||
},
|
||||
@@ -150,6 +162,57 @@
|
||||
},
|
||||
"water_meter_reading": {
|
||||
"name": "Water consumption"
|
||||
},
|
||||
"actual_threshold_electricity": {
|
||||
"name": "Actual threshold electricity"
|
||||
},
|
||||
"electricity_reactive_imported_total": {
|
||||
"name": "Imported reactive electricity (total)"
|
||||
},
|
||||
"electricity_reactive_exported_total": {
|
||||
"name": "Exported reactive electricity (total)"
|
||||
},
|
||||
"electricity_reactive_total_q1": {
|
||||
"name": "Reactive electricity (Q1)"
|
||||
},
|
||||
"electricity_reactive_total_q2": {
|
||||
"name": "Reactive electricity (Q2)"
|
||||
},
|
||||
"electricity_reactive_total_q3": {
|
||||
"name": "Reactive electricity (Q3)"
|
||||
},
|
||||
"electricity_reactive_total_q4": {
|
||||
"name": "Reactive electricity (Q4)"
|
||||
},
|
||||
"electricity_combined": {
|
||||
"name": "Energy combined"
|
||||
},
|
||||
"instantaneous_power_factor_total": {
|
||||
"name": "Instantaneous power factor (total)"
|
||||
},
|
||||
"instantaneous_power_factor_l1": {
|
||||
"name": "Instantaneous power factor L1"
|
||||
},
|
||||
"instantaneous_power_factor_l2": {
|
||||
"name": "Instantaneous power factor L2"
|
||||
},
|
||||
"instantaneous_power_factor_l3": {
|
||||
"name": "Instantaneous power factor L3"
|
||||
},
|
||||
"frequency": {
|
||||
"name": "Frequency"
|
||||
},
|
||||
"fuse_threshold_l1": {
|
||||
"name": "Fuse threshold on L1"
|
||||
},
|
||||
"fuse_threshold_l2": {
|
||||
"name": "Fuse threshold on L2"
|
||||
},
|
||||
"fuse_threshold_l3": {
|
||||
"name": "Fuse threshold on L3"
|
||||
},
|
||||
"text_message": {
|
||||
"name": "Text message"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,12 +60,12 @@
|
||||
"description": "Requests gas prices from easyEnergy.",
|
||||
"fields": {
|
||||
"config_entry": {
|
||||
"name": "Config Entry",
|
||||
"name": "Config entry",
|
||||
"description": "The configuration entry to use for this action."
|
||||
},
|
||||
"incl_vat": {
|
||||
"name": "VAT Included",
|
||||
"description": "Include or exclude VAT in the prices, default is true."
|
||||
"name": "VAT included",
|
||||
"description": "Whether the prices should include VAT."
|
||||
},
|
||||
"start": {
|
||||
"name": "Start",
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.humidifier import (
|
||||
DEFAULT_MAX_HUMIDITY,
|
||||
DEFAULT_MIN_HUMIDITY,
|
||||
MODE_AUTO,
|
||||
HumidifierAction,
|
||||
HumidifierDeviceClass,
|
||||
HumidifierEntity,
|
||||
HumidifierEntityFeature,
|
||||
@@ -41,6 +43,12 @@ async def async_setup_entry(
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
ECOBEE_HUMIDIFIER_ACTION_TO_HASS = {
|
||||
"humidifier": HumidifierAction.HUMIDIFYING,
|
||||
"dehumidifier": HumidifierAction.DRYING,
|
||||
}
|
||||
|
||||
|
||||
class EcobeeHumidifier(HumidifierEntity):
|
||||
"""A humidifier class for an ecobee thermostat with humidifier attached."""
|
||||
|
||||
@@ -52,7 +60,7 @@ class EcobeeHumidifier(HumidifierEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, data, thermostat_index):
|
||||
def __init__(self, data, thermostat_index) -> None:
|
||||
"""Initialize ecobee humidifier platform."""
|
||||
self.data = data
|
||||
self.thermostat_index = thermostat_index
|
||||
@@ -80,11 +88,11 @@ class EcobeeHumidifier(HumidifierEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return if device is available."""
|
||||
return self.thermostat["runtime"]["connected"]
|
||||
|
||||
async def async_update(self):
|
||||
async def async_update(self) -> None:
|
||||
"""Get the latest state from the thermostat."""
|
||||
if self.update_without_throttle:
|
||||
await self.data.update(no_throttle=True)
|
||||
@@ -96,12 +104,20 @@ class EcobeeHumidifier(HumidifierEntity):
|
||||
self._last_humidifier_on_mode = self.mode
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
def action(self) -> HumidifierAction:
|
||||
"""Return the current action."""
|
||||
for status in self.thermostat["equipmentStatus"].split(","):
|
||||
if status in ECOBEE_HUMIDIFIER_ACTION_TO_HASS:
|
||||
return ECOBEE_HUMIDIFIER_ACTION_TO_HASS[status]
|
||||
return HumidifierAction.IDLE if self.is_on else HumidifierAction.OFF
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the humidifier is on."""
|
||||
return self.mode != MODE_OFF
|
||||
|
||||
@property
|
||||
def mode(self):
|
||||
def mode(self) -> str:
|
||||
"""Return the current mode, e.g., off, auto, manual."""
|
||||
return self.thermostat["settings"]["humidifierMode"]
|
||||
|
||||
@@ -118,9 +134,11 @@ class EcobeeHumidifier(HumidifierEntity):
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def set_mode(self, mode):
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""Set humidifier mode (auto, off, manual)."""
|
||||
if mode.lower() not in (self.available_modes):
|
||||
if self.available_modes is None:
|
||||
raise NotImplementedError("Humidifier does not support modes.")
|
||||
if mode.lower() not in self.available_modes:
|
||||
raise ValueError(
|
||||
f"Invalid mode value: {mode} Valid values are"
|
||||
f" {', '.join(self.available_modes)}."
|
||||
@@ -134,10 +152,10 @@ class EcobeeHumidifier(HumidifierEntity):
|
||||
self.data.ecobee.set_humidity(self.thermostat_index, humidity)
|
||||
self.update_without_throttle = True
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Set humidifier to off mode."""
|
||||
self.set_mode(MODE_OFF)
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Set humidifier to on mode."""
|
||||
self.set_mode(self._last_humidifier_on_mode)
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
|
||||
from . import EconetConfigEntry
|
||||
from .const import DOMAIN
|
||||
@@ -35,8 +35,13 @@ ECONET_STATE_TO_HA = {
|
||||
ThermostatOperationMode.OFF: HVACMode.OFF,
|
||||
ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL,
|
||||
ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY,
|
||||
ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT,
|
||||
}
|
||||
HA_STATE_TO_ECONET = {
|
||||
value: key
|
||||
for key, value in ECONET_STATE_TO_HA.items()
|
||||
if key != ThermostatOperationMode.EMERGENCY_HEAT
|
||||
}
|
||||
HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()}
|
||||
|
||||
ECONET_FAN_STATE_TO_HA = {
|
||||
ThermostatFanMode.AUTO: FAN_AUTO,
|
||||
@@ -209,7 +214,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
|
||||
def turn_aux_heat_on(self) -> None:
|
||||
"""Turn auxiliary heater on."""
|
||||
async_create_issue(
|
||||
create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"migrate_aux_heat",
|
||||
@@ -223,7 +228,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
|
||||
def turn_aux_heat_off(self) -> None:
|
||||
"""Turn auxiliary heater off."""
|
||||
async_create_issue(
|
||||
create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
"migrate_aux_heat",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/econet",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.1.27"]
|
||||
"requirements": ["pyeconet==0.1.28"]
|
||||
}
|
||||
|
||||
@@ -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.0.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"]
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
"message": "Params are required for the command: {command}"
|
||||
},
|
||||
"vacuum_raw_get_positions_not_supported": {
|
||||
"message": "Getting the positions of the chargers and the device itself is not supported"
|
||||
"message": "Retrieving the positions of the chargers and the device itself is not supported"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
@@ -264,7 +264,7 @@
|
||||
"services": {
|
||||
"raw_get_positions": {
|
||||
"name": "Get raw positions",
|
||||
"description": "Get the raw response for the positions of the chargers and the device itself."
|
||||
"description": "Retrieves a raw response containing the positions of the chargers and the device itself."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,9 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.DEGREE: SensorEntityDescription(
|
||||
key="DEGREE", native_unit_of_measurement=DEGREE
|
||||
key="DEGREE",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription(
|
||||
key="WATT_METERS_SQUARED",
|
||||
|
||||
@@ -40,12 +40,10 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: str | dict[str, EheimDigitalDevice],
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the climate entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalHeaterClimate] = []
|
||||
if isinstance(device_address, str):
|
||||
device_address = {device_address: coordinator.hub.devices[device_address]}
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalHeater):
|
||||
entities.append(EheimDigitalHeaterClimate(coordinator, device))
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
from aiohttp import ClientError
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.hub import EheimDigitalHub
|
||||
from eheimdigital.types import EheimDeviceType
|
||||
from eheimdigital.types import EheimDeviceType, EheimDigitalClientError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
type AsyncSetupDeviceEntitiesCallback = Callable[
|
||||
[str | dict[str, EheimDigitalDevice]], None
|
||||
]
|
||||
type AsyncSetupDeviceEntitiesCallback = Callable[[dict[str, EheimDigitalDevice]], None]
|
||||
|
||||
type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator]
|
||||
|
||||
@@ -43,12 +43,14 @@ class EheimDigitalUpdateCoordinator(
|
||||
name=DOMAIN,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
)
|
||||
self.main_device_added_event = asyncio.Event()
|
||||
self.hub = EheimDigitalHub(
|
||||
host=self.config_entry.data[CONF_HOST],
|
||||
session=async_get_clientsession(hass),
|
||||
loop=hass.loop,
|
||||
receive_callback=self._async_receive_callback,
|
||||
device_found_callback=self._async_device_found,
|
||||
main_device_added_event=self.main_device_added_event,
|
||||
)
|
||||
self.known_devices: set[str] = set()
|
||||
self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
|
||||
@@ -70,14 +72,23 @@ class EheimDigitalUpdateCoordinator(
|
||||
|
||||
if device_address not in self.known_devices:
|
||||
for platform_callback in self.platform_callbacks:
|
||||
platform_callback(device_address)
|
||||
platform_callback({device_address: self.hub.devices[device_address]})
|
||||
|
||||
async def _async_receive_callback(self) -> None:
|
||||
self.async_set_updated_data(self.hub.devices)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
await self.hub.connect()
|
||||
await self.hub.update()
|
||||
try:
|
||||
await self.hub.connect()
|
||||
async with asyncio.timeout(2):
|
||||
# This event gets triggered when the first message is received from
|
||||
# the device, it contains the data necessary to create the main device.
|
||||
# This removes the race condition where the main device is accessed
|
||||
# before the response from the device is parsed.
|
||||
await self.main_device_added_event.wait()
|
||||
await self.hub.update()
|
||||
except (TimeoutError, EheimDigitalClientError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
async def _async_update_data(self) -> dict[str, EheimDigitalDevice]:
|
||||
try:
|
||||
|
||||
@@ -38,12 +38,10 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def async_setup_device_entities(
|
||||
device_address: str | dict[str, EheimDigitalDevice],
|
||||
device_address: dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the light entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalClassicLEDControlLight] = []
|
||||
if isinstance(device_address, str):
|
||||
device_address = {device_address: coordinator.hub.devices[device_address]}
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicLEDControl):
|
||||
for channel in range(2):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"hop_power_savings": {
|
||||
"default": "mdi:percent"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"hop_selector": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/electric_kiwi",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["electrickiwi-api==0.9.14"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not subscribe to event explicitly.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No actions
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
Has no options flow
|
||||
docs-installation-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
Handled by OAuth flow (HA is only one with credentials, users cannot get them)
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Web services only
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
Web services only
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
Web services only
|
||||
docs-data-update: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
No devices
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
No devices
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
No unnecessary or noisy entities
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
Handled by OAuth
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have any repairs
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Does not have devices
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -13,6 +13,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import ATTRIBUTION
|
||||
from .coordinator import ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
ATTR_EK_HOP_SELECT = "hop_select"
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ from .coordinator import (
|
||||
ElectricKiwiHOPDataCoordinator,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
ATTR_EK_HOP_START = "hop_power_start"
|
||||
ATTR_EK_HOP_END = "hop_power_end"
|
||||
ATTR_TOTAL_RUNNING_BALANCE = "total_running_balance"
|
||||
|
||||
@@ -498,7 +498,11 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle device found via zeroconf."""
|
||||
host = discovery_info.host
|
||||
host = (
|
||||
f"[{discovery_info.ip_address}]"
|
||||
if discovery_info.ip_address.version == 6
|
||||
else str(discovery_info.ip_address)
|
||||
)
|
||||
https_port = (
|
||||
int(discovery_info.port)
|
||||
if discovery_info.port is not None
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"abort": {
|
||||
"metering_point_id_already_configured": "Metering point with ID `{metering_point_id}` is already configured.",
|
||||
"no_metering_points": "The provived API token has no metering points."
|
||||
"no_metering_points": "The provided API token has no metering points."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"api_error": "An error occured in the pyemoncms API : {details}"
|
||||
"api_error": "An error occurred in the pyemoncms API : {details}"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.4"]
|
||||
"requirements": ["sense-energy==0.13.5"]
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ from typing import Any, Final, Literal, cast
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_LAST_RESET,
|
||||
ATTR_STATE_CLASS,
|
||||
RestoreSensor,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor.recorder import reset_detected
|
||||
@@ -222,7 +222,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None:
|
||||
future.set_result(None)
|
||||
|
||||
|
||||
class EnergyCostSensor(SensorEntity):
|
||||
class EnergyCostSensor(RestoreSensor):
|
||||
"""Calculate costs incurred by consuming energy.
|
||||
|
||||
This is intended as a fallback for when no specific cost sensor is available for the
|
||||
@@ -312,40 +312,22 @@ class EnergyCostSensor(SensorEntity):
|
||||
return
|
||||
|
||||
# Determine energy price
|
||||
if self._config["entity_energy_price"] is not None:
|
||||
energy_price_state = self.hass.states.get(
|
||||
self._config["entity_energy_price"]
|
||||
)
|
||||
|
||||
if energy_price_state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
energy_price = float(energy_price_state.state)
|
||||
except ValueError:
|
||||
if self._last_energy_sensor_state is None:
|
||||
# Initialize as it's the first time all required entities except
|
||||
# price are in place. This means that the cost will update the first
|
||||
# time the energy is updated after the price entity is in place.
|
||||
self._reset(energy_state)
|
||||
return
|
||||
|
||||
energy_price_unit: str | None = energy_price_state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT, ""
|
||||
).partition("/")[2]
|
||||
|
||||
# For backwards compatibility we don't validate the unit of the price
|
||||
# If it is not valid, we assume it's our default price unit.
|
||||
if energy_price_unit not in valid_units:
|
||||
energy_price_unit = default_price_unit
|
||||
|
||||
else:
|
||||
energy_price = cast(float, self._config["number_energy_price"])
|
||||
energy_price_unit = default_price_unit
|
||||
energy_price_tuple = self._get_energy_price(valid_units, default_price_unit)
|
||||
if energy_price_tuple is None:
|
||||
return
|
||||
|
||||
if self._last_energy_sensor_state is None:
|
||||
# Initialize as it's the first time all required entities are in place.
|
||||
self._reset(energy_state)
|
||||
# Initialize as it's the first time all required entities are in place or
|
||||
# only the price is missing. In the later case, cost will update the first
|
||||
# time the energy is updated after the price entity is in place.
|
||||
if self._attr_native_value is None:
|
||||
self._reset(energy_state)
|
||||
else:
|
||||
self._last_energy_sensor_state = energy_state
|
||||
return
|
||||
|
||||
energy_price, energy_price_unit = energy_price_tuple
|
||||
if energy_price is None:
|
||||
return
|
||||
|
||||
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
@@ -383,20 +365,9 @@ class EnergyCostSensor(SensorEntity):
|
||||
old_energy_value = float(self._last_energy_sensor_state.state)
|
||||
cur_value = cast(float, self._attr_native_value)
|
||||
|
||||
if energy_price_unit is None:
|
||||
converted_energy_price = energy_price
|
||||
else:
|
||||
converter: Callable[[float, str, str], float]
|
||||
if energy_unit in VALID_ENERGY_UNITS:
|
||||
converter = unit_conversion.EnergyConverter.convert
|
||||
else:
|
||||
converter = unit_conversion.VolumeConverter.convert
|
||||
|
||||
converted_energy_price = converter(
|
||||
energy_price,
|
||||
energy_unit,
|
||||
energy_price_unit,
|
||||
)
|
||||
converted_energy_price = self._convert_energy_price(
|
||||
energy_price, energy_price_unit, energy_unit
|
||||
)
|
||||
|
||||
self._attr_native_value = (
|
||||
cur_value + (energy - old_energy_value) * converted_energy_price
|
||||
@@ -404,8 +375,53 @@ class EnergyCostSensor(SensorEntity):
|
||||
|
||||
self._last_energy_sensor_state = energy_state
|
||||
|
||||
def _get_energy_price(
|
||||
self,
|
||||
valid_units: set[str],
|
||||
default_unit: str | None,
|
||||
) -> tuple[float | None, str | None] | None:
|
||||
if self._config["entity_energy_price"] is None:
|
||||
return cast(float, self._config["number_energy_price"]), default_unit
|
||||
|
||||
energy_price_state = self.hass.states.get(self._config["entity_energy_price"])
|
||||
if energy_price_state is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
energy_price = float(energy_price_state.state)
|
||||
except ValueError:
|
||||
return (None, None)
|
||||
|
||||
energy_price_unit: str | None = energy_price_state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT, ""
|
||||
).partition("/")[2]
|
||||
|
||||
# For backwards compatibility we don't validate the unit of the price
|
||||
# If it is not valid, we assume it's our default price unit.
|
||||
if energy_price_unit not in valid_units:
|
||||
energy_price_unit = default_unit
|
||||
|
||||
return energy_price, energy_price_unit
|
||||
|
||||
def _convert_energy_price(
|
||||
self, energy_price: float, energy_price_unit: str | None, energy_unit: str
|
||||
) -> float:
|
||||
if energy_price_unit is None:
|
||||
return energy_price
|
||||
|
||||
converter: Callable[[float, str, str], float]
|
||||
if energy_unit in VALID_ENERGY_UNITS:
|
||||
converter = unit_conversion.EnergyConverter.convert
|
||||
else:
|
||||
converter = unit_conversion.VolumeConverter.convert
|
||||
|
||||
return converter(energy_price, energy_unit, energy_price_unit)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
if (sensor_data := await self.async_get_last_sensor_data()) is not None:
|
||||
self._attr_native_value = sensor_data.native_value
|
||||
|
||||
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])
|
||||
if energy_state:
|
||||
name = energy_state.name
|
||||
|
||||
@@ -16,7 +16,7 @@ class EnOceanEntity(Entity):
|
||||
"""Initialize the device."""
|
||||
self.dev_id = dev_id
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==1.23.1"],
|
||||
"requirements": ["pyenphase==1.25.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,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
|
||||
|
||||
@@ -192,6 +192,7 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity):
|
||||
"""Return the state of the Enpower switch."""
|
||||
return self.entity_description.value_fn(self.relay)
|
||||
|
||||
@exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Update the relay."""
|
||||
await self.entity_description.update_fn(self.envoy, self.relay, option)
|
||||
@@ -243,6 +244,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
|
||||
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_select_option(self, option: str) -> None:
|
||||
"""Update the relay."""
|
||||
await self.entity_description.update_fn(self.envoy, option)
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"init": {
|
||||
"title": "Envoy {serial} {host} options",
|
||||
"data": {
|
||||
"diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activies. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.",
|
||||
"diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activities. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.",
|
||||
"disable_keep_alive": "Always use a new connection when requesting data from the Envoy. May resolve communication issues with some Envoy firmwares."
|
||||
},
|
||||
"data_description": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = (
|
||||
translation_key="wind_bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"),
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
ECSensorEntityDescription(
|
||||
key="wind_chill",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.0.0",
|
||||
"aioesphomeapi==29.1.1",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.7.1"
|
||||
],
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
},
|
||||
"period": {
|
||||
"name": "Period",
|
||||
"description": "A period of time in days; used only with Away, DayOff, or Custom. The system will revert to Auto at midnight (up to 99 days, today is day 1)."
|
||||
"description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1)."
|
||||
},
|
||||
"duration": {
|
||||
"name": "Duration",
|
||||
"description": "The duration in hours; used only with AutoWithEco (up to 24 hours)."
|
||||
"description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"reset_system": {
|
||||
"name": "Reset system",
|
||||
"description": "Sets the system to Auto mode and reset all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode)."
|
||||
"description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode)."
|
||||
},
|
||||
"refresh_system": {
|
||||
"name": "Refresh system",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"issues": {
|
||||
"import_yaml_error_url_error": {
|
||||
"title": "The Feedreader YAML configuration import failed",
|
||||
"description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessable for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually."
|
||||
"description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user