mirror of
https://github.com/home-assistant/core.git
synced 2026-05-03 19:41:15 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b590cd1e6c | |||
| 25cafd7b43 | |||
| 9a4bcd88db | |||
| 8a7123b880 | |||
| 8e2011a100 | |||
| 26d48e20dd | |||
| 7a340fb676 | |||
| c300e1e376 | |||
| 8aac4777b1 | |||
| fdcaf2897a | |||
| 473b77279f | |||
| 1351304343 | |||
| 980b3023e9 | |||
| 58fa6a06a7 | |||
| 9db63ca774 | |||
| c0d867d0c4 | |||
| 61d64d2d59 | |||
| 402fb8e53a | |||
| 3be9553508 | |||
| 8cd72586ec | |||
| 751f97a462 | |||
| 12909c1877 | |||
| 9e01c14b16 | |||
| 956fbce7d8 | |||
| 3b86a1a2b6 | |||
| a4bd6754df | |||
| 7faf4bfd72 | |||
| ea92047502 | |||
| 2e74a2ad28 | |||
| ab5f20aa69 | |||
| 156ce39202 | |||
| 09cb358015 | |||
| 8beddd2481 | |||
| 4d5e809e9b | |||
| 71af693569 | |||
| 2396fe2245 | |||
| aa19dfacfc |
+27
-31
@@ -37,9 +37,9 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 2
|
||||
CACHE_VERSION: 12
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
HA_SHORT_VERSION: "2025.6"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
@@ -259,7 +259,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
lookup-only: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Install pre-commit dependencies
|
||||
if: steps.cache-precommit.outputs.cache-hit != 'true'
|
||||
@@ -306,7 +306,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
@@ -315,7 +315,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Run ruff-format
|
||||
run: |
|
||||
@@ -346,7 +346,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Run ruff
|
||||
run: |
|
||||
@@ -386,7 +386,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
- name: Restore pre-commit environment from cache
|
||||
id: cache-precommit
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
path: ${{ env.PRE_COMMIT_CACHE }}
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.pre-commit_cache_key }}
|
||||
|
||||
- name: Register yamllint problem matcher
|
||||
@@ -501,7 +501,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore uv wheel cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
@@ -509,10 +509,10 @@ jobs:
|
||||
with:
|
||||
path: ${{ env.UV_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
steps.generate-uv-key.outputs.key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Install additional OS dependencies
|
||||
@@ -598,7 +598,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Run hassfest
|
||||
run: |
|
||||
@@ -631,7 +631,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Run gen_requirements_all.py
|
||||
run: |
|
||||
@@ -653,7 +653,7 @@ jobs:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@v4.7.0
|
||||
uses: actions/dependency-review-action@v4.6.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -688,7 +688,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Extract license data
|
||||
run: |
|
||||
@@ -731,7 +731,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register pylint problem matcher
|
||||
run: |
|
||||
@@ -778,7 +778,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register pylint problem matcher
|
||||
run: |
|
||||
@@ -830,17 +830,17 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore mypy cache
|
||||
uses: actions/cache@v4.2.3
|
||||
with:
|
||||
path: .mypy_cache
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
steps.generate-mypy-key.outputs.key }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-mypy-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-mypy-${{
|
||||
env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}-${{
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Register mypy problem matcher
|
||||
@@ -900,7 +900,7 @@ jobs:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Run split_tests.py
|
||||
run: |
|
||||
@@ -959,8 +959,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
@@ -1085,8 +1084,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
@@ -1220,8 +1218,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
@@ -1372,8 +1369,7 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
fail-on-cache-miss: true
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Register Python problem matcher
|
||||
run: |
|
||||
|
||||
@@ -434,6 +434,7 @@ homeassistant.components.roku.*
|
||||
homeassistant.components.romy.*
|
||||
homeassistant.components.rpi_power.*
|
||||
homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.rtsp_to_webrtc.*
|
||||
homeassistant.components.russound_rio.*
|
||||
homeassistant.components.ruuvi_gateway.*
|
||||
homeassistant.components.ruuvitag_ble.*
|
||||
|
||||
Generated
+8
-8
@@ -46,8 +46,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/accuweather/ @bieniu
|
||||
/homeassistant/components/acmeda/ @atmurray
|
||||
/tests/components/acmeda/ @atmurray
|
||||
/homeassistant/components/adax/ @danielhiversen @lazytarget
|
||||
/tests/components/adax/ @danielhiversen @lazytarget
|
||||
/homeassistant/components/adax/ @danielhiversen
|
||||
/tests/components/adax/ @danielhiversen
|
||||
/homeassistant/components/adguard/ @frenck
|
||||
/tests/components/adguard/ @frenck
|
||||
/homeassistant/components/ads/ @mrpasztoradam
|
||||
@@ -184,8 +184,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/azure_service_bus/ @hfurubotten
|
||||
/homeassistant/components/azure_storage/ @zweckj
|
||||
/tests/components/azure_storage/ @zweckj
|
||||
/homeassistant/components/backblaze/ @frenck
|
||||
/tests/components/backblaze/ @frenck
|
||||
/homeassistant/components/backup/ @home-assistant/core
|
||||
/tests/components/backup/ @home-assistant/core
|
||||
/homeassistant/components/baf/ @bdraco @jfroy
|
||||
@@ -457,8 +455,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/evil_genius_labs/ @balloob
|
||||
/homeassistant/components/evohome/ @zxdavb
|
||||
/tests/components/evohome/ @zxdavb
|
||||
/homeassistant/components/ezviz/ @RenierM26
|
||||
/tests/components/ezviz/ @RenierM26
|
||||
/homeassistant/components/ezviz/ @RenierM26 @baqs
|
||||
/tests/components/ezviz/ @RenierM26 @baqs
|
||||
/homeassistant/components/faa_delays/ @ntilley905
|
||||
/tests/components/faa_delays/ @ntilley905
|
||||
/homeassistant/components/fan/ @home-assistant/core
|
||||
@@ -1113,8 +1111,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/opentherm_gw/ @mvn23
|
||||
/homeassistant/components/openuv/ @bachya
|
||||
/tests/components/openuv/ @bachya
|
||||
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||
/tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck
|
||||
/homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi
|
||||
/tests/components/openweathermap/ @fabaff @freekode @nzapponi
|
||||
/homeassistant/components/opnsense/ @mtreinish
|
||||
/tests/components/opnsense/ @mtreinish
|
||||
/homeassistant/components/opower/ @tronikos
|
||||
@@ -1309,6 +1307,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/rpi_power/ @shenxn @swetoast
|
||||
/homeassistant/components/rss_feed_template/ @home-assistant/core
|
||||
/tests/components/rss_feed_template/ @home-assistant/core
|
||||
/homeassistant/components/rtsp_to_webrtc/ @allenporter
|
||||
/tests/components/rtsp_to_webrtc/ @allenporter
|
||||
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
||||
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
||||
/homeassistant/components/russound_rio/ @noahhusby
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "adax",
|
||||
"name": "Adax",
|
||||
"codeowners": ["@danielhiversen", "@lazytarget"],
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -3,19 +3,6 @@
|
||||
"name": "Airthings",
|
||||
"codeowners": ["@danielhiversen", "@LaStrada"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "airthings-view"
|
||||
},
|
||||
{
|
||||
"hostname": "airthings-hub",
|
||||
"macaddress": "D0141190*"
|
||||
},
|
||||
{
|
||||
"hostname": "airthings-hub",
|
||||
"macaddress": "70B3D52A0*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["airthings"],
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS,
|
||||
EntityCategory,
|
||||
@@ -79,12 +78,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
|
||||
translation_key="light",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"lux": SensorEntityDescription(
|
||||
key="lux",
|
||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||
native_unit_of_measurement=LIGHT_LUX,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
"virusRisk": SensorEntityDescription(
|
||||
key="virusRisk",
|
||||
translation_key="virus_risk",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Choose AlarmDecoder protocol",
|
||||
"title": "Choose AlarmDecoder Protocol",
|
||||
"data": {
|
||||
"protocol": "Protocol"
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"device_baudrate": "Device baud rate",
|
||||
"device_path": "Device path"
|
||||
"device_baudrate": "Device Baud Rate",
|
||||
"device_path": "Device Path"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.",
|
||||
@@ -44,36 +44,36 @@
|
||||
"arm_settings": {
|
||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||
"data": {
|
||||
"auto_bypass": "Auto-bypass on arm",
|
||||
"code_arm_required": "Code required for arming",
|
||||
"alt_night_mode": "Alternative night mode"
|
||||
"auto_bypass": "Auto Bypass on Arm",
|
||||
"code_arm_required": "Code Required for Arming",
|
||||
"alt_night_mode": "Alternative Night Mode"
|
||||
}
|
||||
},
|
||||
"zone_select": {
|
||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||
"description": "Enter the zone number you'd like to to add, edit, or remove.",
|
||||
"data": {
|
||||
"zone_number": "Zone number"
|
||||
"zone_number": "Zone Number"
|
||||
}
|
||||
},
|
||||
"zone_details": {
|
||||
"title": "[%key:component::alarmdecoder::options::step::init::title%]",
|
||||
"description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave 'Zone name' blank.",
|
||||
"description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave Zone Name blank.",
|
||||
"data": {
|
||||
"zone_name": "Zone name",
|
||||
"zone_type": "Zone type",
|
||||
"zone_rfid": "RF serial",
|
||||
"zone_loop": "RF loop",
|
||||
"zone_relayaddr": "Relay address",
|
||||
"zone_relaychan": "Relay channel"
|
||||
"zone_name": "Zone Name",
|
||||
"zone_type": "Zone Type",
|
||||
"zone_rfid": "RF Serial",
|
||||
"zone_loop": "RF Loop",
|
||||
"zone_relayaddr": "Relay Address",
|
||||
"zone_relaychan": "Relay Channel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"relay_inclusive": "'Relay address' and 'Relay channel' are codependent and must be included together.",
|
||||
"relay_inclusive": "Relay Address and Relay Channel are codependent and must be included together.",
|
||||
"int": "The field below must be an integer.",
|
||||
"loop_rfid": "'RF loop' cannot be used without 'RF serial'.",
|
||||
"loop_range": "'RF loop' must be an integer between 1 and 4."
|
||||
"loop_rfid": "RF Loop cannot be used without RF Serial.",
|
||||
"loop_range": "RF Loop must be an integer between 1 and 4."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"step": {
|
||||
"validation": {
|
||||
"title": "Two-factor authentication",
|
||||
"title": "Two factor authentication",
|
||||
"data": {
|
||||
"verification_code": "Verification code"
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"user": {
|
||||
"description": "The inverter must be connected via an RS485 adaptor, please select serial port and the inverter's address as configured on the LCD panel",
|
||||
"data": {
|
||||
"port": "RS485 or USB-RS485 adaptor port",
|
||||
"address": "Inverter address"
|
||||
"port": "RS485 or USB-RS485 Adaptor Port",
|
||||
"address": "Inverter Address"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_serial_ports": "No com ports found. The integration needs a valid RS485 device to communicate."
|
||||
"no_serial_ports": "No com ports found. Need a valid RS485 device to communicate."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Set up two-factor authentication using TOTP",
|
||||
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
|
||||
"description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
"title": "Notify one-time password",
|
||||
"title": "Notify One-Time Password",
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Set up one-time password delivered by notify component",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiobotocore.session import AioSession
|
||||
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
|
||||
@@ -18,7 +17,6 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
AWS_DOMAIN,
|
||||
CONF_ACCESS_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
CONF_ENDPOINT_URL,
|
||||
@@ -59,34 +57,28 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_ENDPOINT_URL: user_input[CONF_ENDPOINT_URL],
|
||||
}
|
||||
)
|
||||
|
||||
if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith(
|
||||
AWS_DOMAIN
|
||||
):
|
||||
try:
|
||||
session = AioSession()
|
||||
async with session.create_client(
|
||||
"s3",
|
||||
endpoint_url=user_input.get(CONF_ENDPOINT_URL),
|
||||
aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY],
|
||||
aws_access_key_id=user_input[CONF_ACCESS_KEY_ID],
|
||||
) as client:
|
||||
await client.head_bucket(Bucket=user_input[CONF_BUCKET])
|
||||
except ClientError:
|
||||
errors["base"] = "invalid_credentials"
|
||||
except ParamValidationError as err:
|
||||
if "Invalid bucket name" in str(err):
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except ValueError:
|
||||
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
|
||||
except ConnectionError:
|
||||
errors[CONF_ENDPOINT_URL] = "cannot_connect"
|
||||
else:
|
||||
try:
|
||||
session = AioSession()
|
||||
async with session.create_client(
|
||||
"s3",
|
||||
endpoint_url=user_input.get(CONF_ENDPOINT_URL),
|
||||
aws_secret_access_key=user_input[CONF_SECRET_ACCESS_KEY],
|
||||
aws_access_key_id=user_input[CONF_ACCESS_KEY_ID],
|
||||
) as client:
|
||||
await client.head_bucket(Bucket=user_input[CONF_BUCKET])
|
||||
except ClientError:
|
||||
errors["base"] = "invalid_credentials"
|
||||
except ParamValidationError as err:
|
||||
if "Invalid bucket name" in str(err):
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except ValueError:
|
||||
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
|
||||
except ConnectionError:
|
||||
errors[CONF_ENDPOINT_URL] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_BUCKET], data=user_input
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_BUCKET], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -12,8 +12,7 @@ CONF_SECRET_ACCESS_KEY = "secret_access_key"
|
||||
CONF_ENDPOINT_URL = "endpoint_url"
|
||||
CONF_BUCKET = "bucket"
|
||||
|
||||
AWS_DOMAIN = "amazonaws.com"
|
||||
DEFAULT_ENDPOINT_URL = f"https://s3.eu-central-1.{AWS_DOMAIN}/"
|
||||
DEFAULT_ENDPOINT_URL = "https://s3.eu-central-1.amazonaws.com/"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
|
||||
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
"invalid_endpoint_url": "Invalid endpoint URL"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Integration for Backblaze B2 Cloud Storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from b2sdk.v2 import AuthInfoCache, B2Api, Bucket, InMemoryAccountInfo
|
||||
from b2sdk.v2.exception import InvalidAuthToken, NonExistentBucket
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
CONF_APPLICATION_KEY,
|
||||
CONF_APPLICATION_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
)
|
||||
|
||||
type BackblazeConfigEntry = ConfigEntry[BackblazeonfigEntryData]
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BackblazeonfigEntryData:
|
||||
"""Dataclass holding all config entry data for a Backblaze entry."""
|
||||
|
||||
api: B2Api
|
||||
bucket: Bucket
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bool:
|
||||
"""Set up Backblaze from a config entry."""
|
||||
|
||||
info = InMemoryAccountInfo()
|
||||
backblaze = B2Api(info, cache=AuthInfoCache(info))
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
backblaze.authorize_account,
|
||||
"production",
|
||||
entry.data[CONF_APPLICATION_KEY_ID],
|
||||
entry.data[CONF_APPLICATION_KEY],
|
||||
)
|
||||
bucket = await hass.async_add_executor_job(
|
||||
backblaze.get_bucket_by_id, entry.data[CONF_BUCKET]
|
||||
)
|
||||
except InvalidAuthToken as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Invalid authentication token for Backblaze account: {err}"
|
||||
) from err
|
||||
except NonExistentBucket as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Non-existent bucket for Backblaze account: {err}"
|
||||
) from err
|
||||
|
||||
entry.runtime_data = BackblazeonfigEntryData(api=backblaze, bucket=bucket)
|
||||
|
||||
# Notify backup listeners
|
||||
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> bool:
|
||||
"""Unload Backblaze config entry."""
|
||||
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
|
||||
return True
|
||||
|
||||
|
||||
async def _notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
@@ -1,234 +0,0 @@
|
||||
"""Backup platform for the Backblaze integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from b2sdk.v2.exception import B2Error
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AddonInfo,
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
Folder,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import BackblazeConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, SEPARATOR
|
||||
from .util import BufferedAsyncIteratorToSyncStream
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
) -> list[BackupAgent]:
|
||||
"""Register the backup agents."""
|
||||
entries: list[BackblazeConfigEntry] = hass.config_entries.async_entries(DOMAIN)
|
||||
return [BackblazeBackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed."""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
class BackblazeBackupAgent(BackupAgent):
|
||||
"""Backblaze backup agent."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: BackblazeConfigEntry) -> None:
|
||||
"""Initialize the Backblaze backup sync agent."""
|
||||
super().__init__()
|
||||
self._bucket = entry.runtime_data.bucket
|
||||
self._api = entry.runtime_data.api
|
||||
self._hass = hass
|
||||
self.name = entry.title
|
||||
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file from Backblaze."""
|
||||
if not await self.async_get_backup(backup_id):
|
||||
raise BackupAgentError("Backup not found")
|
||||
|
||||
try:
|
||||
downloaded_file = await self._hass.async_add_executor_job(
|
||||
self._bucket.download_file_by_name, f"{backup_id}.tar"
|
||||
)
|
||||
except B2Error as err:
|
||||
raise BackupAgentError(
|
||||
f"Failed to download backup {backup_id}: {err}"
|
||||
) from err
|
||||
|
||||
if not downloaded_file.response.ok:
|
||||
raise BackupAgentError(
|
||||
f"Failed to download backup {backup_id}: HTTP {downloaded_file.response.status_code}"
|
||||
)
|
||||
|
||||
# Use an executor to avoid blocking the event loop
|
||||
for chunk in await self._hass.async_add_executor_job(
|
||||
downloaded_file.response.iter_content, 1024
|
||||
):
|
||||
yield chunk
|
||||
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
# Prepare file info metadata to store with the backup in Backblaze
|
||||
# Backblaze can only store a mapping of strings to strings, so we need
|
||||
# to serialize the metadata into a string format.
|
||||
file_info = {
|
||||
"backup_id": backup.backup_id,
|
||||
"database_included": str(backup.database_included).lower(),
|
||||
"date": backup.date,
|
||||
"extra_metadata": "###META###".join(
|
||||
f"{key}{SEPARATOR}{val}" for key, val in backup.extra_metadata.items()
|
||||
),
|
||||
"homeassistant_included": str(backup.homeassistant_included).lower(),
|
||||
"homeassistant_version": backup.homeassistant_version,
|
||||
"name": backup.name,
|
||||
"protected": str(backup.protected).lower(),
|
||||
"size": str(backup.size),
|
||||
}
|
||||
if backup.addons:
|
||||
file_info["addons"] = "###ADDON###".join(
|
||||
f"{addon.slug}{SEPARATOR}{addon.version}{SEPARATOR}{addon.name}"
|
||||
for addon in backup.addons
|
||||
)
|
||||
if backup.folders:
|
||||
file_info["folders"] = ",".join(folder.value for folder in backup.folders)
|
||||
|
||||
iterator = await open_stream()
|
||||
stream = BufferedAsyncIteratorToSyncStream(
|
||||
iterator,
|
||||
buffer_size=8 * 1024 * 1024, # Buffer up to 8MB
|
||||
)
|
||||
try:
|
||||
await self._hass.async_add_executor_job(
|
||||
self._bucket.upload_unbound_stream,
|
||||
stream,
|
||||
f"{backup.backup_id}.tar",
|
||||
"application/octet-stream",
|
||||
file_info,
|
||||
)
|
||||
except B2Error as err:
|
||||
raise BackupAgentError(
|
||||
f"Failed to upload backup {backup.backup_id}: {err}"
|
||||
) from err
|
||||
|
||||
def _delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
) -> None:
|
||||
"""Delete file from Backblaze."""
|
||||
try:
|
||||
file_info = self._bucket.get_file_info_by_name(f"{backup_id}.tar")
|
||||
self._api.delete_file_version(
|
||||
file_info.id_,
|
||||
file_info.file_name,
|
||||
)
|
||||
except B2Error as err:
|
||||
raise BackupAgentError(
|
||||
f"Failed to delete backup {backup_id}: {err}"
|
||||
) from err
|
||||
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file from Backblaze."""
|
||||
if not await self.async_get_backup(backup_id):
|
||||
return
|
||||
|
||||
await self._hass.async_add_executor_job(self._delete_backup, backup_id)
|
||||
|
||||
def _list_backups(self) -> list[AgentBackup]:
|
||||
"""List backups stored on Backblaze."""
|
||||
backups = []
|
||||
try:
|
||||
for file_version, _ in self._bucket.ls(latest_only=True):
|
||||
file_info = file_version.file_info
|
||||
|
||||
if "homeassistant_version" not in file_info:
|
||||
continue
|
||||
|
||||
addons: list[AddonInfo] = []
|
||||
if addons_string := file_version.file_info.get("addons"):
|
||||
for addon in addons_string.split("###ADDON###"):
|
||||
slug, version, name = addon.split(SEPARATOR)
|
||||
addons.append(AddonInfo(slug=slug, version=version, name=name))
|
||||
|
||||
extra_metadata = {}
|
||||
if extra_metadata_string := file_info.get("extra_metadata"):
|
||||
for meta in extra_metadata_string.split("###META###"):
|
||||
key, val = meta.split(SEPARATOR)
|
||||
extra_metadata[key] = val
|
||||
|
||||
folders: list[Folder] = []
|
||||
if folder_string := file_version.file_info.get("folders"):
|
||||
folders = [
|
||||
Folder(folder) for folder in folder_string.split(SEPARATOR)
|
||||
]
|
||||
|
||||
backups.append(
|
||||
AgentBackup(
|
||||
backup_id=file_info["backup_id"],
|
||||
name=file_info["name"],
|
||||
date=file_info["date"],
|
||||
size=int(file_info["size"]),
|
||||
homeassistant_version=file_info["homeassistant_version"],
|
||||
protected=file_info["protected"] == "true",
|
||||
addons=addons,
|
||||
folders=folders,
|
||||
database_included=file_info["database_included"] == "true",
|
||||
homeassistant_included=file_info["database_included"] == "true",
|
||||
extra_metadata=extra_metadata,
|
||||
)
|
||||
)
|
||||
except B2Error as err:
|
||||
raise BackupAgentError(f"Failed to list backups: {err}") from err
|
||||
|
||||
return backups
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups stored on Backblaze."""
|
||||
return await self._hass.async_add_executor_job(self._list_backups)
|
||||
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
backups = await self.async_list_backups()
|
||||
|
||||
for backup in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return backup
|
||||
|
||||
return None
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Config flow to configure the Backblaze B2 Cloud Storage integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from b2sdk.v2 import AuthInfoCache, B2Api, Bucket, InMemoryAccountInfo
|
||||
from b2sdk.v2.exception import InvalidAuthToken
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_APPLICATION_KEY,
|
||||
CONF_APPLICATION_KEY_ID,
|
||||
CONF_BUCKET,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
|
||||
class BackblazeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a Backblaze config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_buckets: Sequence[Bucket]
|
||||
_authorization: dict[str, Any]
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
info = InMemoryAccountInfo()
|
||||
backblaze = B2Api(info, cache=AuthInfoCache(info))
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
backblaze.authorize_account,
|
||||
"production",
|
||||
user_input[CONF_APPLICATION_KEY_ID],
|
||||
user_input[CONF_APPLICATION_KEY],
|
||||
)
|
||||
self._buckets = await self.hass.async_add_executor_job(
|
||||
backblaze.list_buckets,
|
||||
)
|
||||
except InvalidAuthToken:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self._authorization = user_input
|
||||
return await self.async_step_bucket()
|
||||
else:
|
||||
user_input = {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_APPLICATION_KEY_ID,
|
||||
default=user_input.get(CONF_APPLICATION_KEY_ID),
|
||||
): TextSelector(
|
||||
config=TextSelectorConfig(
|
||||
autocomplete="off",
|
||||
),
|
||||
),
|
||||
vol.Required(CONF_APPLICATION_KEY): TextSelector(
|
||||
config=TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
),
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_bucket(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a bucket selection."""
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_BUCKET])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=(
|
||||
next(
|
||||
bucket.name
|
||||
for bucket in self._buckets
|
||||
if bucket.id_ == user_input[CONF_BUCKET]
|
||||
)
|
||||
or "Backblaze"
|
||||
),
|
||||
data=self._authorization | user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="bucket",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_BUCKET,
|
||||
): SelectSelector(
|
||||
config=SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
options=[
|
||||
SelectOptionDict(
|
||||
value=bucket.id_,
|
||||
label=bucket.name,
|
||||
)
|
||||
for bucket in self._buckets
|
||||
],
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Constants for the Backblaze integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "backblaze"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
SEPARATOR: Final = "#!#!#"
|
||||
|
||||
CONF_APPLICATION_KEY_ID: Final = "application_key_id"
|
||||
CONF_APPLICATION_KEY: Final = "application_key"
|
||||
CONF_BUCKET: Final = "bucket"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "backblaze",
|
||||
"name": "Backblaze B2",
|
||||
"codeowners": ["@frenck"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/backblaze",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["b2sdk==2.8.1"]
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: todo
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
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
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"bucket": {
|
||||
"data": {
|
||||
"bucket": "Bucket"
|
||||
},
|
||||
"data_description": {
|
||||
"bucket": "Select the bucket to store backups in."
|
||||
},
|
||||
"description": "Pick the bucket you want to use for backups. Please note, it is best to create a dedicated bucket for Home Assistant backups."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"application_key": "Application key",
|
||||
"application_key_id": "Application key ID"
|
||||
},
|
||||
"data_description": {
|
||||
"application_key": "The application key Backblaze B2 generated for you.",
|
||||
"application_key_id": "The ID of the application key you created in the Backblaze B2 web interface. Not: This is not the same as the key name!"
|
||||
},
|
||||
"description": "Set up your Backblaze B2 bucket to store backups.\n\nTo do so, you will need to create an application key in the Backblaze B2 account. This key will be used to access the bucket you want to use for backups."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Utilities for the Backblaze B2 integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from concurrent.futures import Future
|
||||
import io
|
||||
|
||||
|
||||
class BufferedAsyncIteratorToSyncStream(io.RawIOBase):
|
||||
"""An wrapper to make an AsyncIterator[bytes] a buffered synchronous readable stream."""
|
||||
|
||||
_done: bool = False
|
||||
_read_future: Future[bytes] | None = None
|
||||
|
||||
def __init__(self, iterator: AsyncIterator[bytes], buffer_size: int = 1024) -> None:
|
||||
"""Initialize the stream."""
|
||||
self._buffer = bytearray()
|
||||
self._buffer_size = buffer_size
|
||||
self._iterator = iterator
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
def readable(self) -> bool:
|
||||
"""Mark the stream as readable."""
|
||||
return True
|
||||
|
||||
def _load_next_chunk(self) -> None:
|
||||
"""Load the next chunk into the buffer."""
|
||||
if self._done:
|
||||
return
|
||||
|
||||
if not self._read_future:
|
||||
# Fetch a larger chunk asynchronously
|
||||
self._read_future = asyncio.run_coroutine_threadsafe(
|
||||
self._fetch_next_chunk(), self._loop
|
||||
)
|
||||
|
||||
if self._read_future.done():
|
||||
try:
|
||||
data = self._read_future.result()
|
||||
if data:
|
||||
self._buffer.extend(data)
|
||||
else:
|
||||
self._done = True
|
||||
except StopAsyncIteration:
|
||||
self._done = True
|
||||
except Exception as err: # noqa: BLE001
|
||||
raise io.BlockingIOError(f"Failed to load chunk: {err}") from err
|
||||
finally:
|
||||
self._read_future = None
|
||||
|
||||
async def _fetch_next_chunk(self) -> bytes:
|
||||
"""Fetch multiple chunks until buffer size is filled."""
|
||||
chunks = []
|
||||
total_size = 0
|
||||
|
||||
try:
|
||||
# Fill the buffer up to the specified size
|
||||
while total_size < self._buffer_size:
|
||||
chunk = await anext(self._iterator)
|
||||
chunks.append(chunk)
|
||||
total_size += len(chunk)
|
||||
except StopAsyncIteration:
|
||||
pass # The end, return what we have
|
||||
|
||||
return b"".join(chunks)
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
"""Read bytes."""
|
||||
if size == -1:
|
||||
# Read all remaining data
|
||||
while not self._done:
|
||||
self._load_next_chunk()
|
||||
size = len(self._buffer)
|
||||
|
||||
# Ensure enough data in the buffer
|
||||
while len(self._buffer) < size and not self._done:
|
||||
self._load_next_chunk()
|
||||
|
||||
# Return requested data
|
||||
data = self._buffer[:size]
|
||||
self._buffer = self._buffer[size:]
|
||||
return bytes(data)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the stream."""
|
||||
self._done = True
|
||||
super().close()
|
||||
@@ -22,7 +22,7 @@ from . import util
|
||||
from .agent import BackupAgent
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import BackupManager
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .models import BackupNotFound
|
||||
|
||||
|
||||
@callback
|
||||
@@ -85,15 +85,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
request, headers, backup_id, agent_id, agent, manager
|
||||
)
|
||||
return await self._send_backup_with_password(
|
||||
hass,
|
||||
backup,
|
||||
request,
|
||||
headers,
|
||||
backup_id,
|
||||
agent_id,
|
||||
password,
|
||||
agent,
|
||||
manager,
|
||||
hass, request, headers, backup_id, agent_id, password, agent, manager
|
||||
)
|
||||
except BackupNotFound:
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
@@ -124,7 +116,6 @@ class DownloadBackupView(HomeAssistantView):
|
||||
async def _send_backup_with_password(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
backup: AgentBackup,
|
||||
request: Request,
|
||||
headers: dict[istr, str],
|
||||
backup_id: str,
|
||||
@@ -153,8 +144,7 @@ class DownloadBackupView(HomeAssistantView):
|
||||
|
||||
stream = util.AsyncIteratorWriter(hass)
|
||||
worker = threading.Thread(
|
||||
target=util.decrypt_backup,
|
||||
args=[backup, reader, stream, password, on_done, 0, []],
|
||||
target=util.decrypt_backup, args=[reader, stream, password, on_done, 0, []]
|
||||
)
|
||||
try:
|
||||
worker.start()
|
||||
|
||||
@@ -295,26 +295,13 @@ def validate_password_stream(
|
||||
raise BackupEmpty
|
||||
|
||||
|
||||
def _get_expected_archives(backup: AgentBackup) -> set[str]:
|
||||
"""Get the expected archives in the backup."""
|
||||
expected_archives = set()
|
||||
if backup.homeassistant_included:
|
||||
expected_archives.add("homeassistant")
|
||||
for addon in backup.addons:
|
||||
expected_archives.add(addon.slug)
|
||||
for folder in backup.folders:
|
||||
expected_archives.add(folder.value)
|
||||
return expected_archives
|
||||
|
||||
|
||||
def decrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_stream: IO[bytes],
|
||||
output_stream: IO[bytes],
|
||||
password: str | None,
|
||||
on_done: Callable[[Exception | None], None],
|
||||
minimum_size: int,
|
||||
nonces: NonceGenerator,
|
||||
nonces: list[bytes],
|
||||
) -> None:
|
||||
"""Decrypt a backup."""
|
||||
error: Exception | None = None
|
||||
@@ -328,13 +315,10 @@ def decrypt_backup(
|
||||
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
|
||||
) as output_tar,
|
||||
):
|
||||
_decrypt_backup(backup, input_tar, output_tar, password)
|
||||
_decrypt_backup(input_tar, output_tar, password)
|
||||
except (DecryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error decrypting backup: %s", err)
|
||||
error = err
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
error = err
|
||||
else:
|
||||
# Pad the output stream to the requested minimum size
|
||||
padding = max(minimum_size - output_stream.tell(), 0)
|
||||
@@ -349,18 +333,15 @@ def decrypt_backup(
|
||||
|
||||
|
||||
def _decrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_tar: tarfile.TarFile,
|
||||
output_tar: tarfile.TarFile,
|
||||
password: str | None,
|
||||
) -> None:
|
||||
"""Decrypt a backup."""
|
||||
expected_archives = _get_expected_archives(backup)
|
||||
for obj in input_tar:
|
||||
# We compare with PurePath to avoid issues with different path separators,
|
||||
# for example when backup.json is added as "./backup.json"
|
||||
object_path = PurePath(obj.name)
|
||||
if object_path == PurePath("backup.json"):
|
||||
if PurePath(obj.name) == PurePath("backup.json"):
|
||||
# Rewrite the backup.json file to indicate that the backup is decrypted
|
||||
if not (reader := input_tar.extractfile(obj)):
|
||||
raise DecryptError
|
||||
@@ -371,13 +352,7 @@ def _decrypt_backup(
|
||||
metadata_obj.size = len(updated_metadata_b)
|
||||
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
|
||||
continue
|
||||
prefix, _, suffix = object_path.name.partition(".")
|
||||
if suffix not in ("tar", "tgz", "tar.gz"):
|
||||
LOGGER.debug("Unknown file %s will not be decrypted", obj.name)
|
||||
output_tar.addfile(obj, input_tar.extractfile(obj))
|
||||
continue
|
||||
if prefix not in expected_archives:
|
||||
LOGGER.debug("Unknown inner tar file %s will not be decrypted", obj.name)
|
||||
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
|
||||
output_tar.addfile(obj, input_tar.extractfile(obj))
|
||||
continue
|
||||
istf = SecureTarFile(
|
||||
@@ -396,13 +371,12 @@ def _decrypt_backup(
|
||||
|
||||
|
||||
def encrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_stream: IO[bytes],
|
||||
output_stream: IO[bytes],
|
||||
password: str | None,
|
||||
on_done: Callable[[Exception | None], None],
|
||||
minimum_size: int,
|
||||
nonces: NonceGenerator,
|
||||
nonces: list[bytes],
|
||||
) -> None:
|
||||
"""Encrypt a backup."""
|
||||
error: Exception | None = None
|
||||
@@ -416,13 +390,10 @@ def encrypt_backup(
|
||||
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
|
||||
) as output_tar,
|
||||
):
|
||||
_encrypt_backup(backup, input_tar, output_tar, password, nonces)
|
||||
_encrypt_backup(input_tar, output_tar, password, nonces)
|
||||
except (EncryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error encrypting backup: %s", err)
|
||||
error = err
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
error = err
|
||||
else:
|
||||
# Pad the output stream to the requested minimum size
|
||||
padding = max(minimum_size - output_stream.tell(), 0)
|
||||
@@ -437,20 +408,17 @@ def encrypt_backup(
|
||||
|
||||
|
||||
def _encrypt_backup(
|
||||
backup: AgentBackup,
|
||||
input_tar: tarfile.TarFile,
|
||||
output_tar: tarfile.TarFile,
|
||||
password: str | None,
|
||||
nonces: NonceGenerator,
|
||||
nonces: list[bytes],
|
||||
) -> None:
|
||||
"""Encrypt a backup."""
|
||||
inner_tar_idx = 0
|
||||
expected_archives = _get_expected_archives(backup)
|
||||
for obj in input_tar:
|
||||
# We compare with PurePath to avoid issues with different path separators,
|
||||
# for example when backup.json is added as "./backup.json"
|
||||
object_path = PurePath(obj.name)
|
||||
if object_path == PurePath("backup.json"):
|
||||
if PurePath(obj.name) == PurePath("backup.json"):
|
||||
# Rewrite the backup.json file to indicate that the backup is encrypted
|
||||
if not (reader := input_tar.extractfile(obj)):
|
||||
raise EncryptError
|
||||
@@ -461,21 +429,16 @@ def _encrypt_backup(
|
||||
metadata_obj.size = len(updated_metadata_b)
|
||||
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
|
||||
continue
|
||||
prefix, _, suffix = object_path.name.partition(".")
|
||||
if suffix not in ("tar", "tgz", "tar.gz"):
|
||||
LOGGER.debug("Unknown file %s will not be encrypted", obj.name)
|
||||
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
|
||||
output_tar.addfile(obj, input_tar.extractfile(obj))
|
||||
continue
|
||||
if prefix not in expected_archives:
|
||||
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
|
||||
continue
|
||||
istf = SecureTarFile(
|
||||
None, # Not used
|
||||
gzip=False,
|
||||
key=password_to_key(password) if password is not None else None,
|
||||
mode="r",
|
||||
fileobj=input_tar.extractfile(obj),
|
||||
nonce=nonces.get(inner_tar_idx),
|
||||
nonce=nonces[inner_tar_idx],
|
||||
)
|
||||
inner_tar_idx += 1
|
||||
with istf.encrypt(obj) as encrypted:
|
||||
@@ -493,33 +456,17 @@ class _CipherWorkerStatus:
|
||||
writer: AsyncIteratorWriter
|
||||
|
||||
|
||||
class NonceGenerator:
|
||||
"""Generate nonces for encryption."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the generator."""
|
||||
self._nonces: dict[int, bytes] = {}
|
||||
|
||||
def get(self, index: int) -> bytes:
|
||||
"""Get a nonce for the given index."""
|
||||
if index not in self._nonces:
|
||||
# Generate a new nonce for the given index
|
||||
self._nonces[index] = os.urandom(16)
|
||||
return self._nonces[index]
|
||||
|
||||
|
||||
class _CipherBackupStreamer:
|
||||
"""Encrypt or decrypt a backup."""
|
||||
|
||||
_cipher_func: Callable[
|
||||
[
|
||||
AgentBackup,
|
||||
IO[bytes],
|
||||
IO[bytes],
|
||||
str | None,
|
||||
Callable[[Exception | None], None],
|
||||
int,
|
||||
NonceGenerator,
|
||||
list[bytes],
|
||||
],
|
||||
None,
|
||||
]
|
||||
@@ -537,7 +484,7 @@ class _CipherBackupStreamer:
|
||||
self._hass = hass
|
||||
self._open_stream = open_stream
|
||||
self._password = password
|
||||
self._nonces = NonceGenerator()
|
||||
self._nonces: list[bytes] = []
|
||||
|
||||
def size(self) -> int:
|
||||
"""Return the maximum size of the decrypted or encrypted backup."""
|
||||
@@ -561,15 +508,7 @@ class _CipherBackupStreamer:
|
||||
writer = AsyncIteratorWriter(self._hass)
|
||||
worker = threading.Thread(
|
||||
target=self._cipher_func,
|
||||
args=[
|
||||
self._backup,
|
||||
reader,
|
||||
writer,
|
||||
self._password,
|
||||
on_done,
|
||||
self.size(),
|
||||
self._nonces,
|
||||
],
|
||||
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
|
||||
)
|
||||
worker_status = _CipherWorkerStatus(
|
||||
done=asyncio.Event(), reader=reader, thread=worker, writer=writer
|
||||
@@ -599,6 +538,17 @@ class DecryptedBackupStreamer(_CipherBackupStreamer):
|
||||
class EncryptedBackupStreamer(_CipherBackupStreamer):
|
||||
"""Encrypt a backup."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
backup: AgentBackup,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
password: str | None,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(hass, backup, open_stream, password)
|
||||
self._nonces = [os.urandom(16) for _ in range(self._num_tar_files())]
|
||||
|
||||
_cipher_func = staticmethod(encrypt_backup)
|
||||
|
||||
def backup(self) -> AgentBackup:
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bluemaestro",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bluemaestro-ble==0.4.1"]
|
||||
"requirements": ["bluemaestro-ble==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ from .coordinator import (
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.MEDIA_PLAYER,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Button entities for Bluesound."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyblu import Player
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.const import CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BluesoundCoordinator
|
||||
from .media_player import DEFAULT_PORT
|
||||
from .utils import format_unique_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import BluesoundConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BluesoundConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Bluesound entry."""
|
||||
|
||||
async_add_entities(
|
||||
BluesoundButton(
|
||||
config_entry.runtime_data.coordinator,
|
||||
config_entry.runtime_data.player,
|
||||
config_entry.data[CONF_PORT],
|
||||
description,
|
||||
)
|
||||
for description in BUTTON_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BluesoundButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Description for Bluesound button entities."""
|
||||
|
||||
press_fn: Callable[[Player], Awaitable[None]]
|
||||
|
||||
|
||||
async def clear_sleep_timer(player: Player) -> None:
|
||||
"""Clear the sleep timer."""
|
||||
sleep = -1
|
||||
while sleep != 0:
|
||||
sleep = await player.sleep_timer()
|
||||
|
||||
|
||||
async def set_sleep_timer(player: Player) -> None:
|
||||
"""Set the sleep timer."""
|
||||
await player.sleep_timer()
|
||||
|
||||
|
||||
BUTTON_DESCRIPTIONS = [
|
||||
BluesoundButtonEntityDescription(
|
||||
key="set_sleep_timer",
|
||||
translation_key="set_sleep_timer",
|
||||
entity_registry_enabled_default=False,
|
||||
press_fn=set_sleep_timer,
|
||||
),
|
||||
BluesoundButtonEntityDescription(
|
||||
key="clear_sleep_timer",
|
||||
translation_key="clear_sleep_timer",
|
||||
entity_registry_enabled_default=False,
|
||||
press_fn=clear_sleep_timer,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
|
||||
"""Base class for Bluesound buttons."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: BluesoundButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BluesoundCoordinator,
|
||||
player: Player,
|
||||
port: int,
|
||||
description: BluesoundButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Bluesound button."""
|
||||
super().__init__(coordinator)
|
||||
sync_status = coordinator.data.sync_status
|
||||
|
||||
self.entity_description = description
|
||||
self._player = player
|
||||
self._attr_unique_id = (
|
||||
f"{description.key}-{format_unique_id(sync_status.mac, port)}"
|
||||
)
|
||||
|
||||
if port == DEFAULT_PORT:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
|
||||
name=sync_status.name,
|
||||
manufacturer=sync_status.brand,
|
||||
model=sync_status.model_name,
|
||||
model_id=sync_status.model,
|
||||
)
|
||||
else:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_unique_id(sync_status.mac, port))},
|
||||
name=sync_status.name,
|
||||
manufacturer=sync_status.brand,
|
||||
model=sync_status.model_name,
|
||||
model_id=sync_status.model,
|
||||
via_device=(DOMAIN, format_mac(sync_status.mac)),
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.entity_description.press_fn(self._player)
|
||||
@@ -22,11 +22,7 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_platform,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -38,7 +34,7 @@ from homeassistant.helpers.dispatcher import (
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
|
||||
from .coordinator import BluesoundCoordinator
|
||||
@@ -492,36 +488,10 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
|
||||
|
||||
async def async_increase_timer(self) -> int:
|
||||
"""Increase sleep time on player."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_SET_TIMER}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_set_sleep_timer",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
return await self._player.sleep_timer()
|
||||
|
||||
async def async_clear_timer(self) -> None:
|
||||
"""Clear sleep timer on player."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_service_{SERVICE_CLEAR_TIMER}",
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_service_clear_sleep_timer",
|
||||
translation_placeholders={
|
||||
"name": slugify(self.sync_status.name),
|
||||
},
|
||||
)
|
||||
sleep = 1
|
||||
while sleep > 0:
|
||||
sleep = await self._player.sleep_timer()
|
||||
|
||||
@@ -26,16 +26,6 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_service_set_sleep_timer": {
|
||||
"title": "Detected use of deprecated action bluesound.set_sleep_timer",
|
||||
"description": "Use `button.{name}_set_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts."
|
||||
},
|
||||
"deprecated_service_clear_sleep_timer": {
|
||||
"title": "Detected use of deprecated action bluesound.clear_sleep_timer",
|
||||
"description": "Use `button.{name}_clear_sleep_timer` instead.\n\nPlease replace this action and adjust your automations and scripts."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"join": {
|
||||
"name": "Join",
|
||||
@@ -81,15 +71,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"set_sleep_timer": {
|
||||
"name": "Set sleep timer"
|
||||
},
|
||||
"clear_sleep_timer": {
|
||||
"name": "Clear sleep timer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"name": "Door lock state"
|
||||
},
|
||||
"condition_based_services": {
|
||||
"name": "Condition-based services"
|
||||
"name": "Condition based services"
|
||||
},
|
||||
"check_control_messages": {
|
||||
"name": "Check control messages"
|
||||
@@ -81,7 +81,7 @@
|
||||
"name": "Connection status"
|
||||
},
|
||||
"is_pre_entry_climatization_enabled": {
|
||||
"name": "Pre-entry climatization"
|
||||
"name": "Pre entry climatization"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
|
||||
@@ -14,11 +14,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
|
||||
|
||||
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
||||
|
||||
|
||||
@@ -86,57 +86,3 @@ class BoschAlarmAreaEntity(BoschAlarmEntity):
|
||||
self._area.ready_observer.detach(self.schedule_update_ha_state)
|
||||
if self._observe_status:
|
||||
self._area.status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmDoorEntity(BoschAlarmEntity):
|
||||
"""A base entity for area related entities within a bosch alarm panel."""
|
||||
|
||||
def __init__(self, panel: Panel, door_id: int, unique_id: str) -> None:
|
||||
"""Set up a area related entity for a bosch alarm panel."""
|
||||
super().__init__(panel, unique_id)
|
||||
self._door_id = door_id
|
||||
self._door = panel.doors[door_id]
|
||||
self._door_unique_id = f"{unique_id}_door_{door_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._door_unique_id)},
|
||||
name=self._door.name,
|
||||
manufacturer="Bosch Security Systems",
|
||||
via_device=(DOMAIN, unique_id),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._door.status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._door.status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmOutputEntity(BoschAlarmEntity):
|
||||
"""A base entity for area related entities within a bosch alarm panel."""
|
||||
|
||||
def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None:
|
||||
"""Set up a output related entity for a bosch alarm panel."""
|
||||
super().__init__(panel, unique_id)
|
||||
self._output_id = output_id
|
||||
self._output = panel.outputs[output_id]
|
||||
self._output_unique_id = f"{unique_id}_output_{output_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._output_unique_id)},
|
||||
name=self._output.name,
|
||||
manufacturer="Bosch Security Systems",
|
||||
via_device=(DOMAIN, unique_id),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._output.status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
await super().async_added_to_hass()
|
||||
self._output.status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
@@ -2,27 +2,7 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"faulting_points": {
|
||||
"default": "mdi:alert-circle"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"locked": {
|
||||
"default": "mdi:lock",
|
||||
"state": {
|
||||
"off": "mdi:lock-open"
|
||||
}
|
||||
},
|
||||
"secured": {
|
||||
"default": "mdi:lock",
|
||||
"state": {
|
||||
"off": "mdi:lock-open"
|
||||
}
|
||||
},
|
||||
"cycling": {
|
||||
"default": "mdi:lock",
|
||||
"state": {
|
||||
"on": "mdi:lock-open"
|
||||
}
|
||||
"default": "mdi:alert-circle-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,23 +54,9 @@
|
||||
},
|
||||
"authentication_failed": {
|
||||
"message": "Incorrect credentials for panel."
|
||||
},
|
||||
"incorrect_door_state": {
|
||||
"message": "Door cannot be manipulated while it is being cycled."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"secured": {
|
||||
"name": "Secured"
|
||||
},
|
||||
"cycling": {
|
||||
"name": "Cycling"
|
||||
},
|
||||
"locked": {
|
||||
"name": "Locked"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"faulting_points": {
|
||||
"name": "Faulting points",
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Support for Bosch Alarm Panel outputs and doors as switches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
from bosch_alarm_mode2.panel import Door
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import BoschAlarmDoorEntity, BoschAlarmOutputEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BoschAlarmSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes Bosch Alarm door entity."""
|
||||
|
||||
value_fn: Callable[[Door], bool]
|
||||
on_fn: Callable[[Panel, int], Coroutine[Any, Any, None]]
|
||||
off_fn: Callable[[Panel, int], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
DOOR_SWITCH_TYPES: list[BoschAlarmSwitchEntityDescription] = [
|
||||
BoschAlarmSwitchEntityDescription(
|
||||
key="locked",
|
||||
translation_key="locked",
|
||||
value_fn=lambda door: door.is_locked(),
|
||||
on_fn=lambda panel, door_id: panel.door_relock(door_id),
|
||||
off_fn=lambda panel, door_id: panel.door_unlock(door_id),
|
||||
),
|
||||
BoschAlarmSwitchEntityDescription(
|
||||
key="secured",
|
||||
translation_key="secured",
|
||||
value_fn=lambda door: door.is_secured(),
|
||||
on_fn=lambda panel, door_id: panel.door_secure(door_id),
|
||||
off_fn=lambda panel, door_id: panel.door_unsecure(door_id),
|
||||
),
|
||||
BoschAlarmSwitchEntityDescription(
|
||||
key="cycling",
|
||||
translation_key="cycling",
|
||||
value_fn=lambda door: door.is_cycling(),
|
||||
on_fn=lambda panel, door_id: panel.door_cycle(door_id),
|
||||
off_fn=lambda panel, door_id: panel.door_relock(door_id),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BoschAlarmConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switch entities for outputs."""
|
||||
|
||||
panel = config_entry.runtime_data
|
||||
entities: list[SwitchEntity] = [
|
||||
PanelOutputEntity(
|
||||
panel, output_id, config_entry.unique_id or config_entry.entry_id
|
||||
)
|
||||
for output_id in panel.outputs
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
PanelDoorEntity(
|
||||
panel,
|
||||
door_id,
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
entity_description,
|
||||
)
|
||||
for door_id in panel.doors
|
||||
for entity_description in DOOR_SWITCH_TYPES
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class PanelDoorEntity(BoschAlarmDoorEntity, SwitchEntity):
|
||||
"""A switch entity for a door on a bosch alarm panel."""
|
||||
|
||||
entity_description: BoschAlarmSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
panel: Panel,
|
||||
door_id: int,
|
||||
unique_id: str,
|
||||
entity_description: BoschAlarmSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Set up a switch entity for a door on a bosch alarm panel."""
|
||||
super().__init__(panel, door_id, unique_id)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{self._door_unique_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the value function."""
|
||||
return self.entity_description.value_fn(self._door)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Run the on function."""
|
||||
# If the door is currently cycling, we can't send it any other commands until it is done
|
||||
if self._door.is_cycling():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="incorrect_door_state"
|
||||
)
|
||||
await self.entity_description.on_fn(self.panel, self._door_id)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Run the off function."""
|
||||
# If the door is currently cycling, we can't send it any other commands until it is done
|
||||
if self._door.is_cycling():
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="incorrect_door_state"
|
||||
)
|
||||
await self.entity_description.off_fn(self.panel, self._door_id)
|
||||
|
||||
|
||||
class PanelOutputEntity(BoschAlarmOutputEntity, SwitchEntity):
|
||||
"""An output entity for a bosch alarm panel."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, panel: Panel, output_id: int, unique_id: str) -> None:
|
||||
"""Set up an output entity for a bosch alarm panel."""
|
||||
super().__init__(panel, output_id, unique_id)
|
||||
self._attr_unique_id = self._output_unique_id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Check if this entity is on."""
|
||||
return self._output.is_active()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on this output."""
|
||||
await self.panel.set_output_active(self._output_id)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off this output."""
|
||||
await self.panel.set_output_inactive(self._output_id)
|
||||
@@ -10,12 +10,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import (
|
||||
BringActivityCoordinator,
|
||||
BringConfigEntry,
|
||||
BringCoordinators,
|
||||
BringDataUpdateCoordinator,
|
||||
)
|
||||
from .coordinator import BringConfigEntry, BringDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO]
|
||||
|
||||
@@ -31,10 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo
|
||||
coordinator = BringDataUpdateCoordinator(hass, entry, bring)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
activity_coordinator = BringActivityCoordinator(hass, entry, coordinator)
|
||||
await activity_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = BringCoordinators(coordinator, activity_coordinator)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -30,15 +30,7 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type BringConfigEntry = ConfigEntry[BringCoordinators]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BringCoordinators:
|
||||
"""Data class holding coordinators."""
|
||||
|
||||
data: BringDataUpdateCoordinator
|
||||
activity: BringActivityCoordinator
|
||||
type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -47,27 +39,16 @@ class BringData(DataClassORJSONMixin):
|
||||
|
||||
lst: BringList
|
||||
content: BringItemsResponse
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BringActivityData(DataClassORJSONMixin):
|
||||
"""Coordinator data class."""
|
||||
|
||||
activity: BringActivityResponse
|
||||
users: BringUsersResponse
|
||||
|
||||
|
||||
class BringBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
"""Bring base coordinator."""
|
||||
|
||||
config_entry: BringConfigEntry
|
||||
lists: list[BringList]
|
||||
|
||||
|
||||
class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
|
||||
class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
||||
"""A Bring Data Update Coordinator."""
|
||||
|
||||
config_entry: BringConfigEntry
|
||||
user_settings: BringUserSettingsResponse
|
||||
lists: list[BringList]
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring
|
||||
@@ -109,19 +90,16 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
|
||||
current_lists := {lst.listUuid for lst in self.lists}
|
||||
):
|
||||
self._purge_deleted_lists()
|
||||
new_lists = current_lists - self.previous_lists
|
||||
self.previous_lists = current_lists
|
||||
|
||||
list_dict: dict[str, BringData] = {}
|
||||
for lst in self.lists:
|
||||
if (
|
||||
(ctx := set(self.async_contexts()))
|
||||
and lst.listUuid not in ctx
|
||||
and lst.listUuid not in new_lists
|
||||
):
|
||||
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
|
||||
continue
|
||||
try:
|
||||
items = await self.bring.get_list(lst.listUuid)
|
||||
activity = await self.bring.get_activity(lst.listUuid)
|
||||
users = await self.bring.get_list_users(lst.listUuid)
|
||||
except BringRequestException as e:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -133,7 +111,7 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
|
||||
translation_key="setup_parse_exception",
|
||||
) from e
|
||||
else:
|
||||
list_dict[lst.listUuid] = BringData(lst, items)
|
||||
list_dict[lst.listUuid] = BringData(lst, items, activity, users)
|
||||
|
||||
return list_dict
|
||||
|
||||
@@ -178,60 +156,3 @@ class BringDataUpdateCoordinator(BringBaseCoordinator[dict[str, BringData]]):
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
|
||||
|
||||
class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]]):
|
||||
"""A Bring Activity Data Update Coordinator."""
|
||||
|
||||
user_settings: BringUserSettingsResponse
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: BringConfigEntry,
|
||||
coordinator: BringDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the Bring Activity data coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=10),
|
||||
)
|
||||
|
||||
self.coordinator = coordinator
|
||||
self.lists = coordinator.lists
|
||||
|
||||
async def _async_update_data(self) -> dict[str, BringActivityData]:
|
||||
"""Fetch activity data from bring."""
|
||||
|
||||
list_dict: dict[str, BringActivityData] = {}
|
||||
for lst in self.lists:
|
||||
if (
|
||||
ctx := set(self.coordinator.async_contexts())
|
||||
) and lst.listUuid not in ctx:
|
||||
continue
|
||||
try:
|
||||
activity = await self.coordinator.bring.get_activity(lst.listUuid)
|
||||
users = await self.coordinator.bring.get_list_users(lst.listUuid)
|
||||
except BringAuthException as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_authentication_exception",
|
||||
translation_placeholders={CONF_EMAIL: self.coordinator.bring.mail},
|
||||
) from e
|
||||
except BringRequestException as e:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_request_exception",
|
||||
) from e
|
||||
except BringParseException as e:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_parse_exception",
|
||||
) from e
|
||||
else:
|
||||
list_dict[lst.listUuid] = BringActivityData(activity, users)
|
||||
|
||||
return list_dict
|
||||
|
||||
@@ -20,12 +20,9 @@ async def async_get_config_entry_diagnostics(
|
||||
|
||||
return {
|
||||
"data": {
|
||||
k: v.to_dict() for k, v in config_entry.runtime_data.data.data.items()
|
||||
},
|
||||
"activity": {
|
||||
k: async_redact_data(v.to_dict(), TO_REDACT)
|
||||
for k, v in config_entry.runtime_data.activity.data.items()
|
||||
for k, v in config_entry.runtime_data.data.items()
|
||||
},
|
||||
"lists": [lst.to_dict() for lst in config_entry.runtime_data.data.lists],
|
||||
"user_settings": config_entry.runtime_data.data.user_settings.to_dict(),
|
||||
"lists": [lst.to_dict() for lst in config_entry.runtime_data.lists],
|
||||
"user_settings": config_entry.runtime_data.user_settings.to_dict(),
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BringBaseCoordinator
|
||||
from .coordinator import BringDataUpdateCoordinator
|
||||
|
||||
|
||||
class BringBaseEntity(CoordinatorEntity[BringBaseCoordinator]):
|
||||
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
|
||||
"""Bring base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BringBaseCoordinator,
|
||||
coordinator: BringDataUpdateCoordinator,
|
||||
bring_list: BringList,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
@@ -34,7 +34,5 @@ class BringBaseEntity(CoordinatorEntity[BringBaseCoordinator]):
|
||||
},
|
||||
manufacturer="Bring! Labs AG",
|
||||
model="Bring! Grocery Shopping List",
|
||||
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}"
|
||||
if bring_list in self.coordinator.lists
|
||||
else None,
|
||||
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}",
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BringConfigEntry
|
||||
from .coordinator import BringActivityCoordinator
|
||||
from .coordinator import BringDataUpdateCoordinator
|
||||
from .entity import BringBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -32,18 +32,18 @@ async def async_setup_entry(
|
||||
"""Add event entities."""
|
||||
nonlocal lists_added
|
||||
|
||||
if new_lists := {lst.listUuid for lst in coordinator.data.lists} - lists_added:
|
||||
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
|
||||
async_add_entities(
|
||||
BringEventEntity(
|
||||
coordinator.activity,
|
||||
coordinator,
|
||||
bring_list,
|
||||
)
|
||||
for bring_list in coordinator.data.lists
|
||||
for bring_list in coordinator.lists
|
||||
if bring_list.listUuid in new_lists
|
||||
)
|
||||
lists_added |= new_lists
|
||||
|
||||
coordinator.activity.async_add_listener(add_entities)
|
||||
coordinator.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
|
||||
@@ -51,11 +51,10 @@ class BringEventEntity(BringBaseEntity, EventEntity):
|
||||
"""An event entity."""
|
||||
|
||||
_attr_translation_key = "activities"
|
||||
coordinator: BringActivityCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BringActivityCoordinator,
|
||||
coordinator: BringDataUpdateCoordinator,
|
||||
bring_list: BringList,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
@@ -88,7 +88,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor platform."""
|
||||
coordinator = config_entry.runtime_data.data
|
||||
coordinator = config_entry.runtime_data
|
||||
lists_added: set[str] = set()
|
||||
|
||||
@callback
|
||||
@@ -117,7 +117,6 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
|
||||
"""A sensor entity."""
|
||||
|
||||
entity_description: BringSensorEntityDescription
|
||||
coordinator: BringDataUpdateCoordinator
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -44,7 +44,7 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor from a config entry created in the integrations UI."""
|
||||
coordinator = config_entry.runtime_data.data
|
||||
coordinator = config_entry.runtime_data
|
||||
lists_added: set[str] = set()
|
||||
|
||||
@callback
|
||||
@@ -88,7 +88,6 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
|
||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
|
||||
)
|
||||
coordinator: BringDataUpdateCoordinator
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BringDataUpdateCoordinator, bring_list: BringList
|
||||
@@ -108,9 +107,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
|
||||
description=item.specification,
|
||||
status=TodoItemStatus.NEEDS_ACTION,
|
||||
)
|
||||
for item in sorted(
|
||||
self.bring_list.content.items.purchase, key=lambda i: i.itemId
|
||||
)
|
||||
for item in self.bring_list.content.items.purchase
|
||||
),
|
||||
*(
|
||||
TodoItem(
|
||||
|
||||
@@ -11,13 +11,6 @@
|
||||
},
|
||||
"audio_output": {
|
||||
"default": "mdi:audio-input-stereo-minijack"
|
||||
},
|
||||
"control_bus_mode": {
|
||||
"default": "mdi:audio-video-off",
|
||||
"state": {
|
||||
"amplifier": "mdi:speaker",
|
||||
"receiver": "mdi:audio-video"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -11,7 +11,6 @@ from aiostreammagic import (
|
||||
StreamMagicClient,
|
||||
TransportControl,
|
||||
)
|
||||
from aiostreammagic.models import ControlBusMode
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseMedia,
|
||||
@@ -92,8 +91,6 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
|
||||
features = BASE_FEATURES
|
||||
if self.client.state.pre_amp_mode:
|
||||
features |= PREAMP_FEATURES
|
||||
if self.client.state.control_bus == ControlBusMode.AMPLIFIER:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_STEP
|
||||
if TransportControl.PLAY_PAUSE in controls:
|
||||
features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE
|
||||
for control in controls:
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from aiostreammagic import StreamMagicClient
|
||||
from aiostreammagic.models import ControlBusMode, DisplayBrightness
|
||||
from aiostreammagic.models import DisplayBrightness
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -76,20 +76,6 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
|
||||
value_fn=_audio_output_value_fn,
|
||||
set_value_fn=_audio_output_set_value_fn,
|
||||
),
|
||||
CambridgeAudioSelectEntityDescription(
|
||||
key="control_bus_mode",
|
||||
translation_key="control_bus_mode",
|
||||
options=[
|
||||
ControlBusMode.AMPLIFIER.value,
|
||||
ControlBusMode.RECEIVER.value,
|
||||
ControlBusMode.OFF.value,
|
||||
],
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda client: client.state.control_bus,
|
||||
set_value_fn=lambda client, value: client.set_control_bus_mode(
|
||||
ControlBusMode(value)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -46,14 +46,6 @@
|
||||
},
|
||||
"audio_output": {
|
||||
"name": "Audio output"
|
||||
},
|
||||
"control_bus_mode": {
|
||||
"name": "Control Bus mode",
|
||||
"state": {
|
||||
"amplifier": "Amplifier",
|
||||
"receiver": "Receiver",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -55,11 +55,13 @@ from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
deprecated_function,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.frame import ReportBehavior, report_usage
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType, VolDictType
|
||||
@@ -84,15 +86,18 @@ from .img_util import scale_jpeg_camera_image
|
||||
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
|
||||
from .webrtc import (
|
||||
DATA_ICE_SERVERS,
|
||||
CameraWebRTCLegacyProvider,
|
||||
CameraWebRTCProvider,
|
||||
WebRTCAnswer, # noqa: F401
|
||||
WebRTCAnswer,
|
||||
WebRTCCandidate, # noqa: F401
|
||||
WebRTCClientConfiguration,
|
||||
WebRTCError, # noqa: F401
|
||||
WebRTCError,
|
||||
WebRTCMessage, # noqa: F401
|
||||
WebRTCSendMessage,
|
||||
async_get_supported_legacy_provider,
|
||||
async_get_supported_provider,
|
||||
async_register_ice_servers,
|
||||
async_register_rtsp_to_web_rtc_provider, # noqa: F401
|
||||
async_register_webrtc_provider, # noqa: F401
|
||||
async_register_ws,
|
||||
)
|
||||
@@ -431,6 +436,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"brand",
|
||||
"frame_interval",
|
||||
"frontend_stream_type",
|
||||
"is_on",
|
||||
"is_recording",
|
||||
"is_streaming",
|
||||
@@ -450,6 +456,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
# Entity Properties
|
||||
_attr_brand: str | None = None
|
||||
_attr_frame_interval: float = MIN_STREAM_INTERVAL
|
||||
# Deprecated in 2024.12. Remove in 2025.6
|
||||
_attr_frontend_stream_type: StreamType | None
|
||||
_attr_is_on: bool = True
|
||||
_attr_is_recording: bool = False
|
||||
_attr_is_streaming: bool = False
|
||||
@@ -472,10 +480,24 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self.async_update_token()
|
||||
self._create_stream_lock: asyncio.Lock | None = None
|
||||
self._webrtc_provider: CameraWebRTCProvider | None = None
|
||||
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
|
||||
self._supports_native_sync_webrtc = (
|
||||
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
|
||||
)
|
||||
self._supports_native_async_webrtc = (
|
||||
type(self).async_handle_async_webrtc_offer
|
||||
!= Camera.async_handle_async_webrtc_offer
|
||||
)
|
||||
self._deprecate_attr_frontend_stream_type_logged = False
|
||||
if type(self).frontend_stream_type != Camera.frontend_stream_type:
|
||||
report_usage(
|
||||
(
|
||||
f"is overwriting the 'frontend_stream_type' property in the {type(self).__name__} class,"
|
||||
" which is deprecated and will be removed in Home Assistant 2025.6, "
|
||||
),
|
||||
core_integration_behavior=ReportBehavior.ERROR,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def entity_picture(self) -> str:
|
||||
@@ -537,6 +559,40 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the interval between frames of the mjpeg stream."""
|
||||
return self._attr_frame_interval
|
||||
|
||||
@property
|
||||
def frontend_stream_type(self) -> StreamType | None:
|
||||
"""Return the type of stream supported by this camera.
|
||||
|
||||
A camera may have a single stream type which is used to inform the
|
||||
frontend which camera attributes and player to use. The default type
|
||||
is to use HLS, and components can override to change the type.
|
||||
"""
|
||||
# Deprecated in 2024.12. Remove in 2025.6
|
||||
# Use the camera_capabilities instead
|
||||
if hasattr(self, "_attr_frontend_stream_type"):
|
||||
if not self._deprecate_attr_frontend_stream_type_logged:
|
||||
report_usage(
|
||||
(
|
||||
f"is setting the '_attr_frontend_stream_type' attribute in the {type(self).__name__} class,"
|
||||
" which is deprecated and will be removed in Home Assistant 2025.6, "
|
||||
),
|
||||
core_integration_behavior=ReportBehavior.ERROR,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
|
||||
self._deprecate_attr_frontend_stream_type_logged = True
|
||||
return self._attr_frontend_stream_type
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
return None
|
||||
if (
|
||||
self._webrtc_provider
|
||||
or self._legacy_webrtc_provider
|
||||
or self._supports_native_sync_webrtc
|
||||
or self._supports_native_async_webrtc
|
||||
):
|
||||
return StreamType.WEB_RTC
|
||||
return StreamType.HLS
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
@@ -575,6 +631,15 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""
|
||||
return None
|
||||
|
||||
async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None:
|
||||
"""Handle the WebRTC offer and return an answer.
|
||||
|
||||
This is used by cameras with CameraEntityFeature.STREAM
|
||||
and StreamType.WEB_RTC.
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
|
||||
) -> None:
|
||||
@@ -587,13 +652,56 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
if self._supports_native_sync_webrtc:
|
||||
try:
|
||||
answer = await deprecated_function(
|
||||
"async_handle_async_webrtc_offer",
|
||||
breaks_in_ha_version="2025.6",
|
||||
)(self.async_handle_web_rtc_offer)(offer_sdp)
|
||||
except ValueError as ex:
|
||||
_LOGGER.error("Error handling WebRTC offer: %s", ex)
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
str(ex),
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
# This catch was already here and should stay through the deprecation
|
||||
_LOGGER.error("Timeout handling WebRTC offer")
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
"Timeout handling WebRTC offer",
|
||||
)
|
||||
)
|
||||
else:
|
||||
if answer:
|
||||
send_message(WebRTCAnswer(answer))
|
||||
else:
|
||||
_LOGGER.error("Error handling WebRTC offer: No answer")
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
"No answer on WebRTC offer",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if self._webrtc_provider:
|
||||
await self._webrtc_provider.async_handle_async_webrtc_offer(
|
||||
self, offer_sdp, session_id, send_message
|
||||
)
|
||||
return
|
||||
|
||||
raise HomeAssistantError("Camera does not support WebRTC")
|
||||
if self._legacy_webrtc_provider and (
|
||||
answer := await self._legacy_webrtc_provider.async_handle_web_rtc_offer(
|
||||
self, offer_sdp
|
||||
)
|
||||
):
|
||||
send_message(WebRTCAnswer(answer))
|
||||
else:
|
||||
raise HomeAssistantError("Camera does not support WebRTC")
|
||||
|
||||
def camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
@@ -689,6 +797,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
if motion_detection_enabled := self.motion_detection_enabled:
|
||||
attrs["motion_detection"] = motion_detection_enabled
|
||||
|
||||
if frontend_stream_type := self.frontend_stream_type:
|
||||
attrs["frontend_stream_type"] = frontend_stream_type
|
||||
|
||||
return attrs
|
||||
|
||||
@callback
|
||||
@@ -712,17 +823,28 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
providers or inputs to the state attributes change.
|
||||
"""
|
||||
old_provider = self._webrtc_provider
|
||||
old_legacy_provider = self._legacy_webrtc_provider
|
||||
new_provider = None
|
||||
new_legacy_provider = None
|
||||
|
||||
# Skip all providers if the camera has a native WebRTC implementation
|
||||
if not self._supports_native_async_webrtc:
|
||||
if not (
|
||||
self._supports_native_sync_webrtc or self._supports_native_async_webrtc
|
||||
):
|
||||
# Camera doesn't have a native WebRTC implementation
|
||||
new_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_provider
|
||||
)
|
||||
|
||||
if old_provider != new_provider:
|
||||
if new_provider is None:
|
||||
# Only add the legacy provider if the new provider is not available
|
||||
new_legacy_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_legacy_provider
|
||||
)
|
||||
|
||||
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
|
||||
self._webrtc_provider = new_provider
|
||||
self._legacy_webrtc_provider = new_legacy_provider
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
@@ -747,12 +869,20 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
|
||||
config = self._async_get_webrtc_client_configuration()
|
||||
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
if not self._supports_native_sync_webrtc:
|
||||
# Until 2024.11, the frontend was not resolving any ice servers
|
||||
# The async approach was added 2024.11 and new integrations need to use it
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
config.get_candidates_upfront = (
|
||||
self._supports_native_sync_webrtc
|
||||
or self._legacy_webrtc_provider is not None
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
@@ -782,13 +912,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the camera capabilities."""
|
||||
frontend_stream_types = set()
|
||||
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||
if self._supports_native_async_webrtc:
|
||||
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
|
||||
# The camera has a native WebRTC implementation
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
else:
|
||||
frontend_stream_types.add(StreamType.HLS)
|
||||
|
||||
if self._webrtc_provider:
|
||||
if self._webrtc_provider or self._legacy_webrtc_provider:
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
|
||||
return CameraCapabilities(frontend_stream_types)
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"legacy_webrtc_provider": {
|
||||
"title": "Detected use of legacy WebRTC provider registered by {legacy_integration}",
|
||||
"description": "The {legacy_integration} integration has registered a legacy WebRTC provider. Home Assistant prefers using the built-in modern WebRTC provider registered by the {builtin_integration} integration.\n\nBenefits of the built-in integration are:\n\n- The camera stream is started faster.\n- More camera devices are supported.\n\nTo fix this issue, you can either keep using the built-in modern WebRTC provider and remove the {legacy_integration} integration or remove the {builtin_integration} integration to use the legacy provider, and then restart Home Assistant."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from functools import cache, partial, wraps
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
from mashumaro import MissingField
|
||||
import voluptuous as vol
|
||||
@@ -22,7 +22,8 @@ from webrtc_models import (
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.deprecation import deprecated_function
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.ulid import ulid
|
||||
|
||||
@@ -38,6 +39,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
|
||||
"camera_webrtc_providers"
|
||||
)
|
||||
DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[dict[str, CameraWebRTCLegacyProvider]] = HassKey(
|
||||
"camera_webrtc_legacy_providers"
|
||||
)
|
||||
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
|
||||
"camera_webrtc_ice_servers"
|
||||
)
|
||||
@@ -111,11 +115,13 @@ class WebRTCClientConfiguration:
|
||||
|
||||
configuration: RTCConfiguration = field(default_factory=RTCConfiguration)
|
||||
data_channel: str | None = None
|
||||
get_candidates_upfront: bool = False
|
||||
|
||||
def to_frontend_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict that can be used by the frontend."""
|
||||
data: dict[str, Any] = {
|
||||
"configuration": self.configuration.to_dict(),
|
||||
"getCandidatesUpfront": self.get_candidates_upfront,
|
||||
}
|
||||
if self.data_channel is not None:
|
||||
data["dataChannel"] = self.data_channel
|
||||
@@ -157,6 +163,18 @@ class CameraWebRTCProvider(ABC):
|
||||
return ## This is an optional method so we need a default here.
|
||||
|
||||
|
||||
class CameraWebRTCLegacyProvider(Protocol):
|
||||
"""WebRTC provider."""
|
||||
|
||||
async def async_is_supported(self, stream_source: str) -> bool:
|
||||
"""Determine if the provider supports the stream source."""
|
||||
|
||||
async def async_handle_web_rtc_offer(
|
||||
self, camera: Camera, offer_sdp: str
|
||||
) -> str | None:
|
||||
"""Handle the WebRTC offer and return an answer."""
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_webrtc_provider(
|
||||
hass: HomeAssistant,
|
||||
@@ -186,6 +204,8 @@ def async_register_webrtc_provider(
|
||||
|
||||
async def _async_refresh_providers(hass: HomeAssistant) -> None:
|
||||
"""Check all cameras for any state changes for registered providers."""
|
||||
_async_check_conflicting_legacy_provider(hass)
|
||||
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await asyncio.gather(
|
||||
*(camera.async_refresh_providers() for camera in component.entities)
|
||||
@@ -360,6 +380,21 @@ async def async_get_supported_provider(
|
||||
return None
|
||||
|
||||
|
||||
async def async_get_supported_legacy_provider(
|
||||
hass: HomeAssistant, camera: Camera
|
||||
) -> CameraWebRTCLegacyProvider | None:
|
||||
"""Return the first supported provider for the camera."""
|
||||
providers = hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS)
|
||||
if not providers or not (stream_source := await camera.stream_source()):
|
||||
return None
|
||||
|
||||
for provider in providers.values():
|
||||
if await provider.async_is_supported(stream_source):
|
||||
return provider
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_ice_servers(
|
||||
hass: HomeAssistant,
|
||||
@@ -376,3 +411,94 @@ def async_register_ice_servers(
|
||||
|
||||
servers.append(get_ice_server_fn)
|
||||
return remove
|
||||
|
||||
|
||||
# The following code is legacy code that was introduced with rtsp_to_webrtc and will be deprecated/removed in the future.
|
||||
# Left it so custom integrations can still use it.
|
||||
|
||||
_RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
|
||||
|
||||
# An RtspToWebRtcProvider accepts these inputs:
|
||||
# stream_source: The RTSP url
|
||||
# offer_sdp: The WebRTC SDP offer
|
||||
# stream_id: A unique id for the stream, used to update an existing source
|
||||
# The output is the SDP answer, or None if the source or offer is not eligible.
|
||||
# The Callable may throw HomeAssistantError on failure.
|
||||
type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]]
|
||||
|
||||
|
||||
class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider):
|
||||
def __init__(self, fn: RtspToWebRtcProviderType) -> None:
|
||||
"""Initialize the RTSP to WebRTC provider."""
|
||||
self._fn = fn
|
||||
|
||||
async def async_is_supported(self, stream_source: str) -> bool:
|
||||
"""Return if this provider is supports the Camera as source."""
|
||||
return any(stream_source.startswith(prefix) for prefix in _RTSP_PREFIXES)
|
||||
|
||||
async def async_handle_web_rtc_offer(
|
||||
self, camera: Camera, offer_sdp: str
|
||||
) -> str | None:
|
||||
"""Handle the WebRTC offer and return an answer."""
|
||||
if not (stream_source := await camera.stream_source()):
|
||||
return None
|
||||
|
||||
return await self._fn(stream_source, offer_sdp, camera.entity_id)
|
||||
|
||||
|
||||
@deprecated_function("async_register_webrtc_provider", breaks_in_ha_version="2025.6")
|
||||
def async_register_rtsp_to_web_rtc_provider(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
provider: RtspToWebRtcProviderType,
|
||||
) -> Callable[[], None]:
|
||||
"""Register an RTSP to WebRTC provider.
|
||||
|
||||
The first provider to satisfy the offer will be used.
|
||||
"""
|
||||
if DOMAIN not in hass.data:
|
||||
raise ValueError("Unexpected state, camera not loaded")
|
||||
|
||||
legacy_providers = hass.data.setdefault(DATA_WEBRTC_LEGACY_PROVIDERS, {})
|
||||
|
||||
if domain in legacy_providers:
|
||||
raise ValueError("Provider already registered")
|
||||
|
||||
provider_instance = _CameraRtspToWebRTCProvider(provider)
|
||||
|
||||
@callback
|
||||
def remove_provider() -> None:
|
||||
legacy_providers.pop(domain)
|
||||
hass.async_create_task(_async_refresh_providers(hass))
|
||||
|
||||
legacy_providers[domain] = provider_instance
|
||||
hass.async_create_task(_async_refresh_providers(hass))
|
||||
|
||||
return remove_provider
|
||||
|
||||
|
||||
@callback
|
||||
def _async_check_conflicting_legacy_provider(hass: HomeAssistant) -> None:
|
||||
"""Check if a legacy provider is registered together with the builtin provider."""
|
||||
builtin_provider_domain = "go2rtc"
|
||||
if (
|
||||
(legacy_providers := hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS))
|
||||
and (providers := hass.data.get(DATA_WEBRTC_PROVIDERS))
|
||||
and any(provider.domain == builtin_provider_domain for provider in providers)
|
||||
):
|
||||
for domain in legacy_providers:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"legacy_webrtc_provider_{domain}",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=domain,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/go2rtc/",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="legacy_webrtc_provider",
|
||||
translation_placeholders={
|
||||
"legacy_integration": domain,
|
||||
"builtin_integration": builtin_provider_domain,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -40,10 +40,8 @@ from .prefs import CloudPreferences
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
VALID_REPAIR_TRANSLATION_KEYS = {
|
||||
"no_subscription",
|
||||
"warn_bad_custom_domain_configuration",
|
||||
"reset_bad_custom_domain_configuration",
|
||||
"subscription_expired",
|
||||
}
|
||||
|
||||
|
||||
@@ -405,12 +403,7 @@ class CloudClient(Interface):
|
||||
) -> None:
|
||||
"""Create a repair issue."""
|
||||
if translation_key not in VALID_REPAIR_TRANSLATION_KEYS:
|
||||
_LOGGER.error(
|
||||
"Invalid translation key %s for repair issue %s",
|
||||
translation_key,
|
||||
identifier,
|
||||
)
|
||||
return
|
||||
raise ValueError(f"Invalid translation key {translation_key}")
|
||||
async_create_issue(
|
||||
hass=self._hass,
|
||||
domain=DOMAIN,
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.100.0"],
|
||||
"requirements": ["hass-nabucasa==0.96.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -62,10 +62,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"no_subscription": {
|
||||
"title": "No subscription detected",
|
||||
"description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}."
|
||||
},
|
||||
"warn_bad_custom_domain_configuration": {
|
||||
"title": "Detected wrong custom domain configuration",
|
||||
"description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME."
|
||||
@@ -73,10 +69,6 @@
|
||||
"reset_bad_custom_domain_configuration": {
|
||||
"title": "Custom domain ignored",
|
||||
"description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. This domain has now been ignored and will not be used for Home Assistant Cloud. If you want to use this domain, please fix the DNS configuration and restart Home Assistant. If you do not need this anymore, you can remove it from the account page."
|
||||
},
|
||||
"subscription_expired": {
|
||||
"title": "Subscription has expired",
|
||||
"description": "Your Home Assistant Cloud subscription has expired. Resubscribe at {account_url}."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -77,5 +77,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
|
||||
coordinator = entry.runtime_data
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms):
|
||||
await coordinator.api.logout()
|
||||
await coordinator.api.close()
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -73,6 +73,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
) from err
|
||||
finally:
|
||||
await api.logout()
|
||||
await api.close()
|
||||
|
||||
return {"title": data[CONF_HOST]}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiocomelit==0.12.1"]
|
||||
"requirements": ["aiocomelit==0.12.0"]
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ rules:
|
||||
status: todo
|
||||
comment: missing implementation
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: no config or diagnostic entities
|
||||
status: todo
|
||||
comment: PR in progress
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"cannot_authenticate": {
|
||||
"message": "Error authenticating"
|
||||
},
|
||||
"update_failed": {
|
||||
"updated_failed": {
|
||||
"message": "Failed to update data: {error}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.7"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.4.30"]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"round": "Controls the number of decimal digits in the output.",
|
||||
"time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.",
|
||||
"time_window": "If set, the sensor's value is a time weighted moving average of derivatives within this window.",
|
||||
"unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["devolo_home_control_api"],
|
||||
"requirements": ["devolo-home-control-api==0.19.0"],
|
||||
"requirements": ["devolo-home-control-api==0.18.3"],
|
||||
"zeroconf": ["_dvl-deviceapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.1.1",
|
||||
"aiodiscover==2.7.0",
|
||||
"aiodiscover==2.6.1",
|
||||
"cached-ipaddress==0.10.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ async def async_validate_hostname(
|
||||
result = False
|
||||
with contextlib.suppress(DNSError):
|
||||
result = bool(
|
||||
await aiodns.DNSResolver( # type: ignore[call-overload]
|
||||
await aiodns.DNSResolver(
|
||||
nameservers=[resolver], udp_port=port, tcp_port=port
|
||||
).query(hostname, qtype)
|
||||
)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/dnsip",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["aiodns==3.4.0"]
|
||||
"requirements": ["aiodns==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ class WanIpSensor(SensorEntity):
|
||||
async def async_update(self) -> None:
|
||||
"""Get the current DNS IP address for hostname."""
|
||||
try:
|
||||
response = await self.resolver.query(self.hostname, self.querytype) # type: ignore[call-overload]
|
||||
response = await self.resolver.query(self.hostname, self.querytype)
|
||||
except DNSError as err:
|
||||
_LOGGER.warning("Exception while resolving host: %s", err)
|
||||
response = None
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==13.1.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==13.0.1"]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"phone_number": "Phone number"
|
||||
"phone_number": "Phone Number"
|
||||
}
|
||||
},
|
||||
"one_time_password": {
|
||||
"data": {
|
||||
"one_time_password": "One-time password"
|
||||
"one_time_password": "One Time Password"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"advertise_ip": "Advertise IP address",
|
||||
"advertise_port": "Advertise port",
|
||||
"host_ip": "Host IP address",
|
||||
"listen_port": "Listen port",
|
||||
"advertise_ip": "Advertise IP Address",
|
||||
"advertise_port": "Advertise Port",
|
||||
"host_ip": "Host IP Address",
|
||||
"listen_port": "Listen Port",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"upnp_bind_multicast": "Bind multicast"
|
||||
"upnp_bind_multicast": "Bind multicast (True/False)"
|
||||
},
|
||||
"title": "Define server configuration"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
|
||||
"/ivp/ensemble/generator",
|
||||
"/ivp/meters",
|
||||
"/ivp/meters/readings",
|
||||
"/home",
|
||||
"/home,",
|
||||
]
|
||||
|
||||
for end_point in end_points:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==1.26.1"],
|
||||
"requirements": ["pyenphase==1.26.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -134,22 +134,6 @@ def esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]](
|
||||
return _wrapper
|
||||
|
||||
|
||||
def async_esphome_state_property[_R, _EntityT: EsphomeEntity[Any, Any]](
|
||||
func: Callable[[_EntityT], Awaitable[_R | None]],
|
||||
) -> Callable[[_EntityT], Coroutine[Any, Any, _R | None]]:
|
||||
"""Wrap a state property of an esphome entity.
|
||||
|
||||
This checks if the state object in the entity is set
|
||||
and returns None if it is not set.
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _wrapper(self: _EntityT) -> _R | None:
|
||||
return await func(self) if self._has_state else None
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
def esphome_float_state_property[_EntityT: EsphomeEntity[Any, Any]](
|
||||
func: Callable[[_EntityT], float | None],
|
||||
) -> Callable[[_EntityT], float | None]:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==30.2.0",
|
||||
"aioesphomeapi==30.1.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==2.15.1"
|
||||
],
|
||||
|
||||
@@ -31,7 +31,6 @@ from .coordinator import ESPHomeDashboardCoordinator
|
||||
from .dashboard import async_get_dashboard
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
async_esphome_state_property,
|
||||
convert_api_error_ha_error,
|
||||
esphome_state_property,
|
||||
platform_async_setup_entry,
|
||||
@@ -271,9 +270,7 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
"""A update implementation for esphome."""
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL
|
||||
| UpdateEntityFeature.PROGRESS
|
||||
| UpdateEntityFeature.RELEASE_NOTES
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -303,12 +300,11 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
"""Return the latest version."""
|
||||
return self._state.latest_version
|
||||
|
||||
@async_esphome_state_property
|
||||
async def async_release_notes(self) -> str | None:
|
||||
"""Return the release notes."""
|
||||
if self._state.release_summary:
|
||||
return self._state.release_summary
|
||||
return None
|
||||
@property
|
||||
@esphome_state_property
|
||||
def release_summary(self) -> str:
|
||||
"""Return the release summary."""
|
||||
return self._state.release_summary
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from pyezvizapi.client import EzvizClient
|
||||
from pyezvizapi.exceptions import (
|
||||
from pyezviz.client import EzvizClient
|
||||
from pyezviz.exceptions import (
|
||||
EzvizAuthTokenExpired,
|
||||
EzvizAuthVerificationCode,
|
||||
HTTPError,
|
||||
|
||||
@@ -6,8 +6,8 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyezvizapi import PyEzvizError
|
||||
from pyezvizapi.constants import DefenseModeType
|
||||
from pyezviz import PyEzvizError
|
||||
from pyezviz.constants import DefenseModeType
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
|
||||
@@ -6,9 +6,9 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyezvizapi import EzvizClient
|
||||
from pyezvizapi.constants import SupportExt
|
||||
from pyezvizapi.exceptions import HTTPError, PyEzvizError
|
||||
from pyezviz import EzvizClient
|
||||
from pyezviz.constants import SupportExt
|
||||
from pyezviz.exceptions import HTTPError, PyEzvizError
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pyezvizapi.exceptions import HTTPError, InvalidHost, PyEzvizError
|
||||
from pyezviz.exceptions import HTTPError, InvalidHost, PyEzvizError
|
||||
|
||||
from homeassistant.components import ffmpeg
|
||||
from homeassistant.components.camera import Camera, CameraEntityFeature
|
||||
|
||||
@@ -6,15 +6,15 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyezvizapi.client import EzvizClient
|
||||
from pyezvizapi.exceptions import (
|
||||
from pyezviz.client import EzvizClient
|
||||
from pyezviz.exceptions import (
|
||||
AuthTestResultFailed,
|
||||
EzvizAuthVerificationCode,
|
||||
InvalidHost,
|
||||
InvalidURL,
|
||||
PyEzvizError,
|
||||
)
|
||||
from pyezvizapi.test_cam_rtsp import TestRTSPAuth
|
||||
from pyezviz.test_cam_rtsp import TestRTSPAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
|
||||
@@ -4,8 +4,8 @@ import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyezvizapi.client import EzvizClient
|
||||
from pyezvizapi.exceptions import (
|
||||
from pyezviz.client import EzvizClient
|
||||
from pyezviz.exceptions import (
|
||||
EzvizAuthTokenExpired,
|
||||
EzvizAuthVerificationCode,
|
||||
HTTPError,
|
||||
|
||||
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from propcache.api import cached_property
|
||||
from pyezvizapi.exceptions import PyEzvizError
|
||||
from pyezvizapi.utils import decrypt_image
|
||||
from pyezviz.exceptions import PyEzvizError
|
||||
from pyezviz.utils import decrypt_image
|
||||
|
||||
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
|
||||
from homeassistant.config_entries import SOURCE_IGNORE
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyezvizapi.constants import DeviceCatagories, DeviceSwitchType, SupportExt
|
||||
from pyezvizapi.exceptions import HTTPError, PyEzvizError
|
||||
from pyezviz.constants import DeviceCatagories, DeviceSwitchType, SupportExt
|
||||
from pyezviz.exceptions import HTTPError, PyEzvizError
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"domain": "ezviz",
|
||||
"name": "EZVIZ",
|
||||
"codeowners": ["@RenierM26"],
|
||||
"codeowners": ["@RenierM26", "@baqs"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["ffmpeg"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ezviz",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["paho_mqtt", "pyezvizapi"],
|
||||
"requirements": ["pyezvizapi==1.0.0.7"]
|
||||
"loggers": ["paho_mqtt", "pyezviz"],
|
||||
"requirements": ["pyezviz==0.2.1.2"]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyezvizapi.constants import SupportExt
|
||||
from pyezvizapi.exceptions import (
|
||||
from pyezviz.constants import SupportExt
|
||||
from pyezviz.exceptions import (
|
||||
EzvizAuthTokenExpired,
|
||||
EzvizAuthVerificationCode,
|
||||
HTTPError,
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyezvizapi.constants import DeviceSwitchType, SoundMode
|
||||
from pyezvizapi.exceptions import HTTPError, PyEzvizError
|
||||
from pyezviz.constants import DeviceSwitchType, SoundMode
|
||||
from pyezviz.exceptions import HTTPError, PyEzvizError
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyezvizapi import HTTPError, PyEzvizError, SupportExt
|
||||
from pyezviz import HTTPError, PyEzvizError, SupportExt
|
||||
|
||||
from homeassistant.components.siren import (
|
||||
SirenEntity,
|
||||
|
||||
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyezvizapi.constants import DeviceSwitchType, SupportExt
|
||||
from pyezvizapi.exceptions import HTTPError, PyEzvizError
|
||||
from pyezviz.constants import DeviceSwitchType, SupportExt
|
||||
from pyezviz.exceptions import HTTPError, PyEzvizError
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyezvizapi import HTTPError, PyEzvizError
|
||||
from pyezviz import HTTPError, PyEzvizError
|
||||
|
||||
from homeassistant.components.update import (
|
||||
UpdateDeviceClass,
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"user": {
|
||||
"description": "Make a choice",
|
||||
"menu_options": {
|
||||
"sensor": "Set up a file-based sensor",
|
||||
"sensor": "Set up a file based sensor",
|
||||
"notify": "Set up a notification service"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"title": "File sensor",
|
||||
"description": "[%key:component::file::config::step::user::menu_options::sensor%]",
|
||||
"description": "Set up a file based sensor",
|
||||
"data": {
|
||||
"file_path": "File path",
|
||||
"value_template": "Value template",
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
"outlier": "Outlier",
|
||||
"throttle": "Throttle",
|
||||
"time_throttle": "Time throttle",
|
||||
"time_simple_moving_average": "Moving average (time-based)"
|
||||
"time_simple_moving_average": "Moving Average (Time based)"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["forecast-solar==4.2.0"]
|
||||
"requirements": ["forecast-solar==4.1.0"]
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
available_main_ains = [
|
||||
ain
|
||||
for ain, dev in data.devices.items() | data.templates.items()
|
||||
for ain, dev in data.devices.items()
|
||||
if dev.device_and_unit_id[1] is None
|
||||
]
|
||||
device_reg = dr.async_get(self.hass)
|
||||
|
||||
@@ -45,15 +45,7 @@ type FroniusConfigEntry = ConfigEntry[FroniusSolarNet]
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FroniusConfigEntry) -> bool:
|
||||
"""Set up fronius from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
fronius = Fronius(
|
||||
async_get_clientsession(
|
||||
hass,
|
||||
# Fronius Gen24 firmware 1.35.4-1 redirects to HTTPS with self-signed
|
||||
# certificate. See https://github.com/home-assistant/core/issues/138881
|
||||
verify_ssl=False,
|
||||
),
|
||||
host,
|
||||
)
|
||||
fronius = Fronius(async_get_clientsession(hass), host)
|
||||
solar_net = FroniusSolarNet(hass, entry, fronius)
|
||||
await solar_net.init_devices()
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"ac_module_temperature_sensor_faulty_l2": "AC module temperature sensor faulty (L2)",
|
||||
"dc_component_measured_in_grid_too_high": "DC component measured in the grid too high",
|
||||
"fixed_voltage_mode_out_of_range": "Fixed voltage mode has been selected instead of MPP voltage mode and the fixed voltage has been set to too low or too high a value",
|
||||
"safety_cut_out_triggered": "Safety cut-out via option card or RECERBO has triggered",
|
||||
"safety_cut_out_triggered": "Safety cut out via option card or RECERBO has triggered",
|
||||
"no_communication_between_power_stage_and_control_system": "No communication possible between power stage set and control system",
|
||||
"hardware_id_problem": "Hardware ID problem",
|
||||
"unique_id_conflict": "Unique ID conflict",
|
||||
@@ -148,7 +148,7 @@
|
||||
"update_file_does_not_match_device": "Update file does not match the device, update file too old",
|
||||
"write_or_read_error_occurred": "Write or read error occurred",
|
||||
"file_could_not_be_opened": "File could not be opened",
|
||||
"log_file_cannot_be_saved": "Log file cannot be saved (e.g. USB flash drive is write-protected or full)",
|
||||
"log_file_cannot_be_saved": "Log file cannot be saved (e.g. USB flash drive is write protected or full)",
|
||||
"initialisation_error_file_system_error_on_usb": "Initialization error in file system on USB flash drive",
|
||||
"error_during_logging_data_recording": "Error during recording of logging data",
|
||||
"error_during_update_process": "Error occurred during update process",
|
||||
@@ -166,7 +166,7 @@
|
||||
"invalid_device_type": "Invalid device type",
|
||||
"insulation_measurement_triggered": "Insulation measurement triggered",
|
||||
"inverter_settings_changed_restart_required": "Inverter settings have been changed, inverter restart required",
|
||||
"wired_shut_down_triggered": "Wired shutdown triggered",
|
||||
"wired_shut_down_triggered": "Wired shut down triggered",
|
||||
"grid_frequency_exceeded_limit_reconnecting": "The grid frequency has exceeded a limit value when reconnecting",
|
||||
"mains_voltage_dependent_power_reduction": "Mains voltage-dependent power reduction",
|
||||
"too_little_dc_power_for_feed_in_operation": "Too little DC power for feed-in operation",
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250509.0"]
|
||||
"requirements": ["home-assistant-frontend==20250502.1"]
|
||||
}
|
||||
|
||||
@@ -14,78 +14,49 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
|
||||
DATA_STORAGE: HassKey[tuple[dict[str, Store], dict[str, dict]]] = HassKey(
|
||||
"frontend_storage"
|
||||
)
|
||||
STORAGE_VERSION_USER_DATA = 1
|
||||
|
||||
|
||||
@callback
|
||||
def _initialize_frontend_storage(hass: HomeAssistant) -> None:
|
||||
"""Set up frontend storage."""
|
||||
if DATA_STORAGE in hass.data:
|
||||
return
|
||||
hass.data[DATA_STORAGE] = ({}, {})
|
||||
|
||||
|
||||
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
|
||||
"""Set up frontend storage."""
|
||||
_initialize_frontend_storage(hass)
|
||||
websocket_api.async_register_command(hass, websocket_set_user_data)
|
||||
websocket_api.async_register_command(hass, websocket_get_user_data)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
|
||||
|
||||
|
||||
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
|
||||
async def async_user_store(
|
||||
hass: HomeAssistant, user_id: str
|
||||
) -> tuple[Store, dict[str, Any]]:
|
||||
"""Access a user store."""
|
||||
stores = hass.data.setdefault(DATA_STORAGE, {})
|
||||
_initialize_frontend_storage(hass)
|
||||
stores, data = hass.data[DATA_STORAGE]
|
||||
if (store := stores.get(user_id)) is None:
|
||||
store = stores[user_id] = UserStore(hass, user_id)
|
||||
await store.async_load()
|
||||
|
||||
return store
|
||||
|
||||
|
||||
class UserStore:
|
||||
"""User store for frontend data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, user_id: str) -> None:
|
||||
"""Initialize the user store."""
|
||||
self._store = _UserStore(hass, user_id)
|
||||
self.data: dict[str, Any] = {}
|
||||
self.subscriptions: dict[str | None, list[Callable[[], None]]] = {}
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load the data from the store."""
|
||||
self.data = await self._store.async_load() or {}
|
||||
|
||||
async def async_set_item(self, key: str, value: Any) -> None:
|
||||
"""Set an item item and save the store."""
|
||||
self.data[key] = value
|
||||
await self._store.async_save(self.data)
|
||||
for cb in self.subscriptions.get(None, []):
|
||||
cb()
|
||||
for cb in self.subscriptions.get(key, []):
|
||||
cb()
|
||||
|
||||
@callback
|
||||
def async_subscribe(
|
||||
self, key: str | None, on_update_callback: Callable[[], None]
|
||||
) -> Callable[[], None]:
|
||||
"""Save the data to the store."""
|
||||
self.subscriptions.setdefault(key, []).append(on_update_callback)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from the store."""
|
||||
self.subscriptions[key].remove(on_update_callback)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
class _UserStore(Store[dict[str, Any]]):
|
||||
"""User store for frontend data."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, user_id: str) -> None:
|
||||
"""Initialize the user store."""
|
||||
super().__init__(
|
||||
store = stores[user_id] = Store(
|
||||
hass,
|
||||
STORAGE_VERSION_USER_DATA,
|
||||
f"frontend.user_data_{user_id}",
|
||||
)
|
||||
|
||||
if user_id not in data:
|
||||
data[user_id] = await store.async_load() or {}
|
||||
|
||||
def with_user_store(
|
||||
return store, data[user_id]
|
||||
|
||||
|
||||
def with_store(
|
||||
orig_func: Callable[
|
||||
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
|
||||
[HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]],
|
||||
Coroutine[Any, Any, None],
|
||||
],
|
||||
) -> Callable[
|
||||
@@ -94,17 +65,17 @@ def with_user_store(
|
||||
"""Decorate function to provide data."""
|
||||
|
||||
@wraps(orig_func)
|
||||
async def with_user_store_func(
|
||||
async def with_store_func(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Provide user specific data and store to function."""
|
||||
user_id = connection.user.id
|
||||
|
||||
store = await async_user_store(hass, user_id)
|
||||
store, user_data = await async_user_store(hass, user_id)
|
||||
|
||||
await orig_func(hass, connection, msg, store)
|
||||
await orig_func(hass, connection, msg, store, user_data)
|
||||
|
||||
return with_user_store_func
|
||||
return with_store_func
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -115,57 +86,41 @@ def with_user_store(
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@with_user_store
|
||||
@with_store
|
||||
async def websocket_set_user_data(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
store: UserStore,
|
||||
store: Store,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle set user data command."""
|
||||
await store.async_set_item(msg["key"], msg["value"])
|
||||
connection.send_result(msg["id"])
|
||||
"""Handle set global data command.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
data[msg["key"]] = msg["value"]
|
||||
await store.async_save(data)
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "frontend/get_user_data", vol.Optional("key"): str}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@with_user_store
|
||||
@with_store
|
||||
async def websocket_get_user_data(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
store: UserStore,
|
||||
store: Store,
|
||||
data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Handle get user data command."""
|
||||
data = store.data
|
||||
connection.send_result(
|
||||
msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data}
|
||||
)
|
||||
"""Handle get global data command.
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "frontend/subscribe_user_data", vol.Optional("key"): str}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@with_user_store
|
||||
async def websocket_subscribe_user_data(
|
||||
hass: HomeAssistant,
|
||||
connection: ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
store: UserStore,
|
||||
) -> None:
|
||||
"""Handle subscribe to user data command."""
|
||||
key: str | None = msg.get("key")
|
||||
|
||||
def on_data_update() -> None:
|
||||
"""Handle user data update."""
|
||||
data = store.data
|
||||
connection.send_event(
|
||||
msg["id"], {"value": data.get(key) if key is not None else data}
|
||||
Async friendly.
|
||||
"""
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"], {"value": data.get(msg["key"]) if "key" in msg else data}
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
|
||||
on_data_update()
|
||||
connection.send_result(msg["id"])
|
||||
)
|
||||
|
||||
@@ -2,17 +2,26 @@
|
||||
|
||||
from goodwe import InverterError, connect
|
||||
|
||||
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.device_registry import DeviceInfo
|
||||
|
||||
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
|
||||
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
|
||||
from .const import (
|
||||
CONF_MODEL_FAMILY,
|
||||
DOMAIN,
|
||||
KEY_COORDINATOR,
|
||||
KEY_DEVICE_INFO,
|
||||
KEY_INVERTER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import GoodweUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the Goodwe components from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
host = entry.data[CONF_HOST]
|
||||
model_family = entry.data[CONF_MODEL_FAMILY]
|
||||
|
||||
@@ -41,11 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = GoodweRuntimeData(
|
||||
inverter=inverter,
|
||||
coordinator=coordinator,
|
||||
device_info=device_info,
|
||||
)
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
KEY_INVERTER: inverter,
|
||||
KEY_COORDINATOR: coordinator,
|
||||
KEY_DEVICE_INFO: device_info,
|
||||
}
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
@@ -54,13 +63,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
||||
) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
|
||||
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
@@ -8,12 +8,13 @@ import logging
|
||||
from goodwe import Inverter, InverterError
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import GoodweConfigEntry
|
||||
from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,12 +36,12 @@ SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoodweConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the inverter button entities from a config entry."""
|
||||
inverter = config_entry.runtime_data.inverter
|
||||
device_info = config_entry.runtime_data.device_info
|
||||
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
|
||||
device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO]
|
||||
|
||||
# read current time from the inverter
|
||||
try:
|
||||
|
||||
@@ -12,3 +12,7 @@ DEFAULT_NAME = "GoodWe"
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
CONF_MODEL_FAMILY = "model_family"
|
||||
|
||||
KEY_INVERTER = "inverter"
|
||||
KEY_COORDINATOR = "coordinator"
|
||||
KEY_DEVICE_INFO = "device_info"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -10,34 +9,22 @@ from goodwe import Inverter, InverterError, RequestFailedException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type GoodweConfigEntry = ConfigEntry[GoodweRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GoodweRuntimeData:
|
||||
"""Data class for runtime data."""
|
||||
|
||||
inverter: Inverter
|
||||
coordinator: GoodweUpdateCoordinator
|
||||
device_info: DeviceInfo
|
||||
|
||||
|
||||
class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Gather data for the energy device."""
|
||||
|
||||
config_entry: GoodweConfigEntry
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: GoodweConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
inverter: Inverter,
|
||||
) -> None:
|
||||
"""Initialize update coordinator."""
|
||||
|
||||
@@ -4,16 +4,19 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from goodwe import Inverter
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import GoodweConfigEntry
|
||||
from .const import DOMAIN, KEY_INVERTER
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
inverter = config_entry.runtime_data.inverter
|
||||
inverter: Inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
|
||||
|
||||
return {
|
||||
"config_entry": config_entry.as_dict(),
|
||||
|
||||
@@ -13,13 +13,13 @@ from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GoodweConfigEntry
|
||||
from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,12 +86,12 @@ NUMBERS = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: GoodweConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the inverter select entities from a config entry."""
|
||||
inverter = config_entry.runtime_data.inverter
|
||||
device_info = config_entry.runtime_data.device_info
|
||||
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
|
||||
device_info = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE_INFO]
|
||||
|
||||
entities = []
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user