diff --git a/.coveragerc b/.coveragerc index f15d36918ec..c012c8e686e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -404,6 +404,9 @@ omit = homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py + homeassistant/components/flexit_bacnet/__init__.py + homeassistant/components/flexit_bacnet/const.py + homeassistant/components/flexit_bacnet/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/sensor.py @@ -633,8 +636,6 @@ omit = homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py - homeassistant/components/komfovent/__init__.py - homeassistant/components/komfovent/climate.py homeassistant/components/konnected/__init__.py homeassistant/components/konnected/panel.py homeassistant/components/konnected/switch.py @@ -902,6 +903,9 @@ omit = homeassistant/components/opple/light.py homeassistant/components/oru/* homeassistant/components/orvibo/switch.py + homeassistant/components/osoenergy/__init__.py + homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/overkiz/__init__.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 9d13c07301e..7c3a42aaaa1 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 71030e50074..2255b3f145c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 6 - HA_SHORT_VERSION: "2023.12" + HA_SHORT_VERSION: "2024.1" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -309,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -854,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -978,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e7d9d4cd901..c9e6bb8fcc8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.8 + uses: github/codeql-action/init@v2.22.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.8 + uses: github/codeql-action/analyze@v2.22.9 with: category: "/language:python" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c91117cb02d..b51550767b8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,16 +11,16 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - # The 90 day stale policy for PRs + # The 60 day stale policy for PRs # Used for: # - PRs # - No PRs marked as no-stale # - No issues (-1) - - name: 90 days stale PRs policy - uses: actions/stale@v8.0.0 + - name: 60 days stale PRs policy + uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 90 + days-before-stale: 60 days-before-close: 7 days-before-issue-stale: -1 days-before-issue-close: -1 @@ -33,7 +33,11 @@ jobs: pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. - Thank you for your contributions. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! # Generate a token for the GitHub App, we use this method to avoid # hitting API limits for our GitHub actions + have a higher rate limit. @@ -53,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f72b71b8802..c8e25cc83ea 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.strict-typing b/.strict-typing index 3c18a1988f3..6180379f977 100644 --- a/.strict-typing +++ b/.strict-typing @@ -120,6 +120,7 @@ homeassistant.components.energy.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* +homeassistant.components.faa_delays.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* homeassistant.components.feedreader.* @@ -127,6 +128,7 @@ homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* homeassistant.components.fitbit.* +homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* homeassistant.components.forecast_solar.* homeassistant.components.fritz.* @@ -150,6 +152,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.holiday.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* @@ -264,6 +267,7 @@ homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* +homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* @@ -313,6 +317,7 @@ homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* +homeassistant.components.streamlabswater.* homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* diff --git a/CODEOWNERS b/CODEOWNERS index ec32f941d56..dad0d51ad79 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -86,6 +86,8 @@ build.json @home-assistant/supervisor /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex +/homeassistant/components/aosmith/ @bdr99 +/tests/components/aosmith/ @bdr99 /homeassistant/components/apache_kafka/ @bachya /tests/components/apache_kafka/ @bachya /homeassistant/components/apcupsd/ @yuxincs @@ -205,8 +207,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington -/homeassistant/components/co2signal/ @jpbede -/tests/components/co2signal/ @jpbede +/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 +/tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent @@ -395,6 +397,8 @@ build.json @home-assistant/supervisor /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus +/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski +/tests/components/flexit_bacnet/ @lellky @piotrbulinski /homeassistant/components/flick_electric/ @ZephireNZ /tests/components/flick_electric/ @ZephireNZ /homeassistant/components/flipr/ @cnico @@ -520,6 +524,8 @@ build.json @home-assistant/supervisor /tests/components/hive/ @Rendili @KJonline /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard +/homeassistant/components/holiday/ @jrieger +/tests/components/holiday/ @jrieger /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub /homeassistant/components/home_plus_control/ @chemaaa @@ -663,8 +669,6 @@ build.json @home-assistant/supervisor /tests/components/knx/ @Julius2342 @farmio @marvin-w /homeassistant/components/kodi/ @OnFreund /tests/components/kodi/ @OnFreund -/homeassistant/components/komfovent/ @ProstoSanja -/tests/components/komfovent/ @ProstoSanja /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm @@ -928,6 +932,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/osoenergy/ @osohotwateriot +/tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund @@ -1247,6 +1253,8 @@ build.json @home-assistant/supervisor /homeassistant/components/suez_water/ @ooii /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig +/homeassistant/components/sunweg/ @rokam +/tests/components/sunweg/ @rokam /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen @@ -1295,6 +1303,8 @@ build.json @home-assistant/supervisor /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tessie/ @Bre77 +/tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core /homeassistant/components/tfiac/ @fredrike @mellado @@ -1398,8 +1408,8 @@ build.json @home-assistant/supervisor /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/Dockerfile b/Dockerfile index b61e1461c52..43b21ab3ba8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker ARG BUILD_FROM FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ - S6_SERVICES_GRACETIME=220000 + S6_SERVICES_GRACETIME=240000 ARG QEMU_CPU diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0998ac6274c..83b2f18719f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,7 @@ from .const import ( from .exceptions import HomeAssistantError from .helpers import ( area_registry, + config_validation as cv, device_registry, entity, entity_registry, @@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] - domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} + domains = { + domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN + } # Add config entry domains if not hass.config.recovery_mode: diff --git a/homeassistant/brands/flexit.json b/homeassistant/brands/flexit.json new file mode 100644 index 00000000000..4c61c5eeb07 --- /dev/null +++ b/homeassistant/brands/flexit.json @@ -0,0 +1,5 @@ +{ + "domain": "flexit", + "name": "Flexit", + "integrations": ["flexit", "flexit_bacnet"] +} diff --git a/homeassistant/components/agent_dvr/strings.json b/homeassistant/components/agent_dvr/strings.json index 77167b8294b..cbfc2e87a4d 100644 --- a/homeassistant/components/agent_dvr/strings.json +++ b/homeassistant/components/agent_dvr/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The IP address of the Agent DVR server." } } }, diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 82719515cbf..d1a2340b4bc 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -3,7 +3,6 @@ from typing import Final DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -TARGET_ROUTE: Final = "average" CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 2d0d9d199df..76459005c45 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL +from .const import DOMAIN, MANUFACTURER, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -56,6 +56,4 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) + return await self.airq.get_latest_data() diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 97fb70c1b05..156f167913b 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.2.4"] + "requirements": ["aioairq==0.3.1"] } diff --git a/homeassistant/components/airtouch4/strings.json b/homeassistant/components/airtouch4/strings.json index c810b991b8e..04c2e54cc7e 100644 --- a/homeassistant/components/airtouch4/strings.json +++ b/homeassistant/components/airtouch4/strings.json @@ -14,7 +14,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of the device running your AirTouch controller." + "host": "The hostname or IP address of your AirTouch controller." } } } diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json index 6f690c9eb4a..641fa8963da 100644 --- a/homeassistant/components/airvisual_pro/strings.json +++ b/homeassistant/components/airvisual_pro/strings.json @@ -14,7 +14,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "The hostname or IP address of the device running your AirVisual Pro." + "ip_address": "The hostname or IP address of your AirVisual Pro device." } } }, diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index e1b5a774a87..dd698201b09 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -16,7 +16,7 @@ "device_path": "Device Path" }, "data_description": { - "host": "The hostname or IP address of the machine connected to the AlarmDecoder device.", + "host": "The hostname or IP address of the AlarmDecoder device that is connected to your alarm panel.", "port": "The port on which AlarmDecoder is accessible (for example, 10000)" } } diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 219553b3563..2a9637772b1 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -36,6 +36,15 @@ CONF_FLASH_BRIEFINGS = "flash_briefings" CONF_SMART_HOME = "smart_home" DEFAULT_LOCALE = "en-US" +# Alexa Smart Home API send events gateway endpoints +# https://developer.amazon.com/en-US/docs/alexa/smarthome/send-events.html#endpoints +VALID_ENDPOINTS = [ + "https://api.amazonalexa.com/v3/events", + "https://api.eu.amazonalexa.com/v3/events", + "https://api.fe.amazonalexa.com/v3/events", +] + + ALEXA_ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_DESCRIPTION): cv.string, @@ -46,7 +55,7 @@ ALEXA_ENTITY_SCHEMA = vol.Schema( SMART_HOME_SCHEMA = vol.Schema( { - vol.Optional(CONF_ENDPOINT): cv.string, + vol.Optional(CONF_ENDPOINT): vol.All(vol.Lower, vol.In(VALID_ENDPOINTS)), vol.Optional(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In( diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index f99b0231e4d..2796c10795b 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1304,13 +1304,14 @@ async def async_api_set_range( service = None data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Cover Position if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE and range_value == 0: service = cover.SERVICE_CLOSE_COVER - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN and range_value == 100: service = cover.SERVICE_OPEN_COVER else: service = cover.SERVICE_SET_COVER_POSITION @@ -1319,9 +1320,9 @@ async def async_api_set_range( # Cover Tilt elif instance == f"{cover.DOMAIN}.tilt": range_value = int(range_value) - if range_value == 0: + if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0: service = cover.SERVICE_CLOSE_COVER_TILT - elif range_value == 100: + elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100: service = cover.SERVICE_OPEN_COVER_TILT else: service = cover.SERVICE_SET_COVER_TILT_POSITION @@ -1332,13 +1333,11 @@ async def async_api_set_range( range_value = int(range_value) if range_value == 0: service = fan.SERVICE_TURN_OFF + elif supported & fan.FanEntityFeature.SET_SPEED: + service = fan.SERVICE_SET_PERCENTAGE + data[fan.ATTR_PERCENTAGE] = range_value else: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported and fan.FanEntityFeature.SET_SPEED: - service = fan.SERVICE_SET_PERCENTAGE - data[fan.ATTR_PERCENTAGE] = range_value - else: - service = fan.SERVICE_TURN_ON + service = fan.SERVICE_TURN_ON # Humidifier target humidity elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_HUMIDITY}": diff --git a/homeassistant/components/android_ip_webcam/strings.json b/homeassistant/components/android_ip_webcam/strings.json index db21a690984..57e5452b900 100644 --- a/homeassistant/components/android_ip_webcam/strings.json +++ b/homeassistant/components/android_ip_webcam/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The IP address of the device running the Android IP Webcam app. The IP address is shown in the app once you start the server." } } }, diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py new file mode 100644 index 00000000000..af780e012ae --- /dev/null +++ b/homeassistant/components/aosmith/__init__.py @@ -0,0 +1,53 @@ +"""The A. O. Smith integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from py_aosmith import AOSmithAPIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AOSmithCoordinator + +PLATFORMS: list[Platform] = [Platform.WATER_HEATER] + + +@dataclass +class AOSmithData: + """Data for the A. O. Smith integration.""" + + coordinator: AOSmithCoordinator + client: AOSmithAPIClient + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up A. O. Smith from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + + session = aiohttp_client.async_get_clientsession(hass) + client = AOSmithAPIClient(email, password, session) + coordinator = AOSmithCoordinator(hass, client) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData( + coordinator=coordinator, client=client + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py new file mode 100644 index 00000000000..36a1c215d68 --- /dev/null +++ b/homeassistant/components/aosmith/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for A. O. Smith integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from py_aosmith import AOSmithAPIClient, AOSmithInvalidCredentialsException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for A. O. Smith.""" + + VERSION = 1 + + _reauth_email: str | None + + def __init__(self): + """Start the config flow.""" + self._reauth_email = None + + async def _async_validate_credentials( + self, email: str, password: str + ) -> str | None: + """Validate the credentials. Return an error string, or None if successful.""" + session = aiohttp_client.async_get_clientsession(self.hass) + client = AOSmithAPIClient(email, password, session) + + try: + await client.get_devices() + except AOSmithInvalidCredentialsException: + return "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + unique_id = user_input[CONF_EMAIL].lower() + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + error = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if error is None: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL, default=self._reauth_email): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth if the user credentials have changed.""" + self._reauth_email = entry_data[CONF_EMAIL] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user's reauth credentials.""" + errors: dict[str, str] = {} + if user_input is not None and self._reauth_email is not None: + email = self._reauth_email + password = user_input[CONF_PASSWORD] + entry_id = self.context["entry_id"] + + if entry := self.hass.config_entries.async_get_entry(entry_id): + error = await self._async_validate_credentials(email, password) + if error is None: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | user_input, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_EMAIL: self._reauth_email}, + errors=errors, + ) diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py new file mode 100644 index 00000000000..06794582258 --- /dev/null +++ b/homeassistant/components/aosmith/const.py @@ -0,0 +1,16 @@ +"""Constants for the A. O. Smith integration.""" + +from datetime import timedelta + +DOMAIN = "aosmith" + +AOSMITH_MODE_ELECTRIC = "ELECTRIC" +AOSMITH_MODE_HEAT_PUMP = "HEAT_PUMP" +AOSMITH_MODE_HYBRID = "HYBRID" +AOSMITH_MODE_VACATION = "VACATION" + +# Update interval to be used for normal background updates. +REGULAR_INTERVAL = timedelta(seconds=30) + +# Update interval to be used while a mode or setpoint change is in progress. +FAST_INTERVAL = timedelta(seconds=1) diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py new file mode 100644 index 00000000000..bdd144569dd --- /dev/null +++ b/homeassistant/components/aosmith/coordinator.py @@ -0,0 +1,51 @@ +"""The data update coordinator for the A. O. Smith integration.""" + +import logging +from typing import Any + +from py_aosmith import ( + AOSmithAPIClient, + AOSmithInvalidCredentialsException, + AOSmithUnknownException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, FAST_INTERVAL, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AOSmithCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Custom data update coordinator for A. O. Smith integration.""" + + def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) + self.client = client + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch latest data from API.""" + try: + devices = await self.client.get_devices() + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + mode_pending = any( + device.get("data", {}).get("modePending") for device in devices + ) + setpoint_pending = any( + device.get("data", {}).get("temperatureSetpointPending") + for device in devices + ) + + if mode_pending or setpoint_pending: + self.update_interval = FAST_INTERVAL + else: + self.update_interval = REGULAR_INTERVAL + + return {device.get("junctionId"): device for device in devices} diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py new file mode 100644 index 00000000000..20061ca36b9 --- /dev/null +++ b/homeassistant/components/aosmith/entity.py @@ -0,0 +1,51 @@ +"""The base entity for the A. O. Smith integration.""" + + +from py_aosmith import AOSmithAPIClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AOSmithCoordinator + + +class AOSmithEntity(CoordinatorEntity[AOSmithCoordinator]): + """Base entity for A. O. Smith.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.junction_id = junction_id + self._attr_device_info = DeviceInfo( + manufacturer="A. O. Smith", + name=self.device.get("name"), + model=self.device.get("model"), + serial_number=self.device.get("serial"), + suggested_area=self.device.get("install", {}).get("location"), + identifiers={(DOMAIN, junction_id)}, + sw_version=self.device.get("data", {}).get("firmwareVersion"), + ) + + @property + def device(self): + """Shortcut to get the device status from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) + + @property + def device_data(self): + """Shortcut to get the device data within the device status.""" + device = self.device + return None if device is None else device.get("data", {}) + + @property + def client(self) -> AOSmithAPIClient: + """Shortcut to get the API client.""" + return self.coordinator.client + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.device_data.get("isOnline") is True diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json new file mode 100644 index 00000000000..2e3a459d7e2 --- /dev/null +++ b/homeassistant/components/aosmith/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "aosmith", + "name": "A. O. Smith", + "codeowners": ["@bdr99"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aosmith", + "iot_class": "cloud_polling", + "quality_scale": "platinum", + "requirements": ["py-aosmith==1.0.1"] +} diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json new file mode 100644 index 00000000000..26de264bab9 --- /dev/null +++ b/homeassistant/components/aosmith/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter your A. O. Smith credentials." + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py new file mode 100644 index 00000000000..8002373573f --- /dev/null +++ b/homeassistant/components/aosmith/water_heater.py @@ -0,0 +1,149 @@ +"""The water heater platform for the A. O. Smith integration.""" + +from typing import Any + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, + DOMAIN, +) +from .coordinator import AOSmithCoordinator +from .entity import AOSmithEntity + +MODE_HA_TO_AOSMITH = { + STATE_OFF: AOSMITH_MODE_VACATION, + STATE_ECO: AOSMITH_MODE_HYBRID, + STATE_ELECTRIC: AOSMITH_MODE_ELECTRIC, + STATE_HEAT_PUMP: AOSMITH_MODE_HEAT_PUMP, +} +MODE_AOSMITH_TO_HA = { + AOSMITH_MODE_ELECTRIC: STATE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP: STATE_HEAT_PUMP, + AOSMITH_MODE_HYBRID: STATE_ECO, + AOSMITH_MODE_VACATION: STATE_OFF, +} + +# Operation mode to use when exiting away mode +DEFAULT_OPERATION_MODE = AOSMITH_MODE_HYBRID + +DEFAULT_SUPPORT_FLAGS = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith water heater platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + for junction_id in data.coordinator.data: + entities.append(AOSmithWaterHeaterEntity(data.coordinator, junction_id)) + + async_add_entities(entities) + + +class AOSmithWaterHeaterEntity(AOSmithEntity, WaterHeaterEntity): + """The water heater entity for the A. O. Smith integration.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_temp = 95 + + def __init__(self, coordinator: AOSmithCoordinator, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = junction_id + + @property + def operation_list(self) -> list[str]: + """Return the list of supported operation modes.""" + op_modes = [] + for mode_dict in self.device_data.get("modes", []): + mode_name = mode_dict.get("mode") + ha_mode = MODE_AOSMITH_TO_HA.get(mode_name) + + # Filtering out STATE_OFF since it is handled by away mode + if ha_mode is not None and ha_mode != STATE_OFF: + op_modes.append(ha_mode) + + return op_modes + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the list of supported features.""" + supports_vacation_mode = any( + mode_dict.get("mode") == AOSMITH_MODE_VACATION + for mode_dict in self.device_data.get("modes", []) + ) + + if supports_vacation_mode: + return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE + + return DEFAULT_SUPPORT_FLAGS + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.device_data.get("temperatureSetpoint") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device_data.get("temperatureSetpointMaximum") + + @property + def current_operation(self) -> str: + """Return the current operation mode.""" + return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF) + + @property + def is_away_mode_on(self): + """Return True if away mode is on.""" + return self.device_data.get("mode") == AOSMITH_MODE_VACATION + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + aosmith_mode = MODE_HA_TO_AOSMITH.get(operation_mode) + if aosmith_mode is not None: + await self.client.update_mode(self.junction_id, aosmith_mode) + + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get("temperature") + await self.client.update_setpoint(self.junction_id, temperature) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + await self.client.update_mode(self.junction_id, AOSMITH_MODE_VACATION) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + await self.client.update_mode(self.junction_id, DEFAULT_OPERATION_MODE) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 321da56095a..98d464ec526 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -7,8 +7,9 @@ from datetime import timedelta import logging from typing import Final -from apcaccess import status +import aioapcaccess +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -32,6 +33,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): updates from the server. """ + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize the data object.""" super().__init__( @@ -70,13 +73,10 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): return self.data.get("SERIALNO") @property - def device_info(self) -> DeviceInfo | None: + def device_info(self) -> DeviceInfo: """Return the DeviceInfo of this APC UPS, if serial number is available.""" - if not self.ups_serial_no: - return None - return DeviceInfo( - identifiers={(DOMAIN, self.ups_serial_no)}, + identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)}, model=self.ups_model, manufacturer="APC", name=self.ups_name if self.ups_name else "APC UPS", @@ -90,13 +90,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): Note that the result dict uses upper case for each resource, where our integration uses lower cases as keys internally. """ - async with asyncio.timeout(10): try: - raw = await self.hass.async_add_executor_job( - status.get, self._host, self._port - ) - result: OrderedDict[str, str] = status.parse(raw) - return result - except OSError as error: + return await aioapcaccess.request_status(self._host, self._port) + except (OSError, asyncio.IncompleteReadError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 55b66f0c0a0..b20e0c8aacf 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["apcaccess"], "quality_scale": "silver", - "requirements": ["apcaccess==0.0.13"] + "requirements": ["aioapcaccess==0.4.2"] } diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 6bb3cc34050..057e85613fd 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -41,7 +41,6 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -218,9 +217,11 @@ class APIStatesView(HomeAssistantView): if entity_perm(state.entity_id, "read") ) response = web.Response( - body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON + body=f'[{",".join(states)}]', + content_type=CONTENT_TYPE_JSON, + zlib_executor_size=32768, ) - enable_compression(response) + response.enable_compression() return response @@ -390,17 +391,14 @@ class APIDomainServicesView(HomeAssistantView): ) try: - async with timeout(SERVICE_WAIT_TIMEOUT): - # shield the service call from cancellation on connection drop - await shield( - hass.services.async_call( - domain, service, data, blocking=True, context=context - ) + # shield the service call from cancellation on connection drop + await shield( + hass.services.async_call( + domain, service, data, blocking=True, context=context ) + ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest() from ex - except TimeoutError: - pass finally: cancel_listen() diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1eb32a9dc3f..ed9029d1c2c 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -9,7 +9,7 @@ from dataclasses import asdict, dataclass, field from enum import StrEnum import logging from pathlib import Path -from queue import Queue +from queue import Empty, Queue from threading import Thread import time from typing import TYPE_CHECKING, Any, Final, cast @@ -1010,8 +1010,8 @@ class PipelineRun: self.tts_engine = engine self.tts_options = tts_options - async def text_to_speech(self, tts_input: str) -> str: - """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" + async def text_to_speech(self, tts_input: str) -> None: + """Run text-to-speech portion of pipeline.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -1024,43 +1024,40 @@ class PipelineRun: ) ) - try: - # Synthesize audio and get URL - tts_media_id = tts_generate_media_source_id( - self.hass, - tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, - ) - except Exception as src_error: - _LOGGER.exception("Unexpected error during text-to-speech") - raise TextToSpeechError( - code="tts-failed", - message="Unexpected error during text-to-speech", - ) from src_error + if tts_input := tts_input.strip(): + try: + # Synthesize audio and get URL + tts_media_id = tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.tts_engine, + language=self.pipeline.tts_language, + options=self.tts_options, + ) + tts_media = await media_source.async_resolve_media( + self.hass, + tts_media_id, + None, + ) + except Exception as src_error: + _LOGGER.exception("Unexpected error during text-to-speech") + raise TextToSpeechError( + code="tts-failed", + message="Unexpected error during text-to-speech", + ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + _LOGGER.debug("TTS result %s", tts_media) + tts_output = { + "media_id": tts_media_id, + **asdict(tts_media), + } + else: + tts_output = {} self.process_event( - PipelineEvent( - PipelineEventType.TTS_END, - { - "tts_output": { - "media_id": tts_media_id, - **asdict(tts_media), - } - }, - ) + PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) - return tts_media.url - def _capture_chunk(self, audio_bytes: bytes | None) -> None: """Forward audio chunk to various capturing mechanisms.""" if self.debug_recording_queue is not None: @@ -1247,6 +1244,8 @@ def _pipeline_debug_recording_thread_proc( # Chunk of 16-bit mono audio at 16Khz if wav_writer is not None: wav_writer.writeframes(message) + except Empty: + pass # occurs when pipeline has unexpected error except Exception: # pylint: disable=broad-exception-caught _LOGGER.exception("Unexpected error in debug recording thread") finally: diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 83f99ecc76a..228da7f1a36 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -55,7 +55,9 @@ _LOGGER = logging.getLogger(__name__) _AsusWrtBridgeT = TypeVar("_AsusWrtBridgeT", bound="AsusWrtBridge") -_FuncType = Callable[[_AsusWrtBridgeT], Awaitable[list[Any] | dict[str, Any]]] +_FuncType = Callable[ + [_AsusWrtBridgeT], Awaitable[list[Any] | tuple[Any] | dict[str, Any]] +] _ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any]]] @@ -81,7 +83,7 @@ def handle_errors_and_zip( if isinstance(data, dict): return dict(zip(keys, list(data.values()))) - if not isinstance(data, list): + if not isinstance(data, (list, tuple)): raise UpdateFailed("Received invalid data type") return dict(zip(keys, data)) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index cf105a6a708..8a3207ec7cb 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -2,7 +2,6 @@ "config": { "step": { "user": { - "title": "AsusWRT", "description": "Set required parameter to connect to your router", "data": { "host": "[%key:common::config_flow::data::host%]", @@ -11,10 +10,12 @@ "ssh_key": "Path to your SSH key file (instead of password)", "protocol": "Communication protocol to use", "port": "Port (leave empty for protocol default)" + }, + "data_description": { + "host": "The hostname or IP address of your ASUSWRT router." } }, "legacy": { - "title": "AsusWRT", "description": "Set required parameters to connect to your router", "data": { "mode": "Router operating mode" @@ -37,7 +38,6 @@ "options": { "step": { "init": { - "title": "AsusWRT Options", "data": { "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", diff --git a/homeassistant/components/atag/strings.json b/homeassistant/components/atag/strings.json index 39ed972524d..82070c0209f 100644 --- a/homeassistant/components/atag/strings.json +++ b/homeassistant/components/atag/strings.json @@ -2,10 +2,13 @@ "config": { "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Atag device." } } }, diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 43e3bd2ad5c..39abba4ada5 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): power_watts = self.client.measure(3, True) temperature_c = self.client.measure(21) energy_wh = self.client.cumulated_energy(5) + [alarm, *_] = self.client.alarms() except AuroraTimeoutError: self.available = False _LOGGER.debug("No response from inverter (could be dark)") @@ -86,6 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): data["instantaneouspower"] = round(power_watts, 1) data["temp"] = round(temperature_c, 1) data["totalenergy"] = round(energy_wh / 1000, 2) + data["alarm"] = alarm self.available = True finally: diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 0e7d0c06a4e..80b0fd656b6 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,6 +5,8 @@ from collections.abc import Mapping import logging from typing import Any +from aurorapy.mapping import Mapping as AuroraMapping + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -36,8 +38,16 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +ALARM_STATES = list(AuroraMapping.ALARM_STATES.values()) SENSOR_TYPES = [ + SensorEntityDescription( + key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_STATES, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="alarm", + ), SensorEntityDescription( key="instantaneouspower", device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 50b6e0db502..63ea1cfefd4 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -21,11 +21,14 @@ }, "entity": { "sensor": { + "alarm": { + "name": "Alarm status" + }, "power_output": { - "name": "Power Output" + "name": "Power output" }, "total_energy": { - "name": "Total Energy" + "name": "Total energy" } } } diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ed801772e6d..ff0fe43ea26 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_CONDITION, @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv, script +from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 47a25b542a7..8c302dba201 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -3,12 +3,16 @@ "flow_title": "{name} ({host})", "step": { "user": { - "title": "Set up Axis device", + "description": "Set up an Axis device", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of the Axis device.", + "username": "The user name you set up on your Axis device. It is recommended to create a user specifically for Home Assistant." } } }, diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 059603fc589..e2d1c5fcb3a 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -93,8 +93,6 @@ class BAFFan(BAFEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_AUTO: - raise ValueError(f"Invalid preset mode: {preset_mode}") self._device.fan_mode = OffOnAuto.AUTO async def async_set_direction(self, direction: str) -> None: diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 238deb7d65d..e0af12514da 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -2,9 +2,12 @@ "config": { "step": { "user": { - "title": "Connect to the Balboa Wi-Fi device", + "description": "Connect to the Balboa Wi-Fi device", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } } }, diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 42ad5cabeb7..d83c2686563 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import BlinkUpdateCoordinator -from .services import async_setup_services +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -74,7 +74,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Blink.""" - await async_setup_services(hass) + setup_services(hass) return True diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 8ea0b6c03a4..dae2f0ad951 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -1,8 +1,6 @@ """Services for the Blink integration.""" from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -14,7 +12,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr @@ -27,56 +25,72 @@ from .const import ( ) from .coordinator import BlinkUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - +SERVICE_UPDATE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + } +) SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string, } ) SERVICE_SEND_PIN_SCHEMA = vol.Schema( - {vol.Required(ATTR_DEVICE_ID): cv.ensure_list, vol.Optional(CONF_PIN): cv.string} + { + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_PIN): cv.string, + } ) SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): cv.ensure_list, + vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string, } ) -async def async_setup_services(hass: HomeAssistant) -> None: +def setup_services(hass: HomeAssistant) -> None: """Set up the services for the Blink integration.""" - async def collect_coordinators( + def collect_coordinators( device_ids: list[str], ) -> list[BlinkUpdateCoordinator]: - config_entries = list[ConfigEntry]() + config_entries: list[ConfigEntry] = [] registry = dr.async_get(hass) for target in device_ids: device = registry.async_get(target) if device: - device_entries = list[ConfigEntry]() + device_entries: list[ConfigEntry] = [] for entry_id in device.config_entries: entry = hass.config_entries.async_get_entry(entry_id) if entry and entry.domain == DOMAIN: device_entries.append(entry) if not device_entries: - raise HomeAssistantError( - f"Device '{target}' is not a {DOMAIN} device" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_device", + translation_placeholders={"target": target, "domain": DOMAIN}, ) config_entries.extend(device_entries) else: raise HomeAssistantError( - f"Device '{target}' not found in device registry" + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"target": target}, ) - coordinators = list[BlinkUpdateCoordinator]() + + coordinators: list[BlinkUpdateCoordinator] = [] for config_entry in config_entries: if config_entry.state != ConfigEntryState.LOADED: - raise HomeAssistantError(f"{config_entry.title} is not loaded") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators @@ -85,24 +99,36 @@ async def async_setup_services(hass: HomeAssistant) -> None: camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): - _LOGGER.error("Can't write %s, no access to path!", video_path) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": video_path}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: await all_cameras[camera_name].video_to_file(video_path) except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] if not hass.config.is_allowed_path(clips_dir): - _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) - return - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": clips_dir}, + ) + + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): all_cameras = coordinator.api.cameras if camera_name in all_cameras: try: @@ -110,11 +136,15 @@ async def async_setup_services(hass: HomeAssistant) -> None: output_dir=clips_dir ) except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -122,12 +152,12 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" - for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services service_mapping = [ - (blink_refresh, SERVICE_REFRESH, None), + (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA), ( async_handle_save_video_service, SERVICE_SAVE_VIDEO, diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 95f4d33f91f..aaecde64353 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,14 +1,28 @@ # Describes the format for available Blink services blink_update: + fields: + device_id: + required: true + selector: + device: + integration: blink + trigger_camera: - target: - entity: - integration: blink - domain: camera + fields: + device_id: + required: true + selector: + device: + integration: blink save_video: fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -22,6 +36,11 @@ save_video: save_recent_clips: fields: + device_id: + required: true + selector: + device: + integration: blink name: required: true example: "Living Room" @@ -35,6 +54,11 @@ save_recent_clips: send_pin: fields: + device_id: + required: true + selector: + device: + integration: blink pin: example: "abc123" selector: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index c29c4c765b7..fc0450dc8ea 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -57,11 +57,23 @@ "services": { "blink_update": { "name": "Update", - "description": "Forces a refresh." + "description": "Forces a refresh.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "trigger_camera": { "name": "Trigger camera", - "description": "Requests camera to take new image." + "description": "Requests camera to take new image.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "The Blink device id." + } + } }, "save_video": { "name": "Save video", @@ -74,6 +86,10 @@ "filename": { "name": "File name", "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -88,6 +104,10 @@ "file_path": { "name": "Output directory", "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } }, @@ -98,8 +118,29 @@ "pin": { "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." + }, + "device_id": { + "name": "Device ID", + "description": "The Blink device id." } } } + }, + "exceptions": { + "invalid_device": { + "message": "Device '{target}' is not a {domain} device" + }, + "device_not_found": { + "message": "Device '{target}' not found in device registry" + }, + "no_path": { + "message": "Can't write to directory {target}, no access to path!" + }, + "cant_write": { + "message": "Can't write to file" + }, + "not_loaded": { + "message": "{target} is not loaded" + } } } diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index ddf57aa6eee..63a1c1b45f0 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -215,7 +215,7 @@ class DomainBlueprints: def _load_blueprint(self, blueprint_path) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) + blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) except FileNotFoundError as err: raise FailedToLoad( self.domain, @@ -225,7 +225,6 @@ class DomainBlueprints: except HomeAssistantError as err: raise FailedToLoad(self.domain, blueprint_path, err) from err - assert isinstance(blueprint_data, dict) return Blueprint( blueprint_data, expected_domain=self.domain, path=blueprint_path ) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c59249e8bd5..329b597d515 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,6 +21,12 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothConnector, + HaScanner, + ScannerStartError, +) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -59,7 +65,11 @@ from .api import ( async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice +from .base_scanner import ( + BaseHaScanner, + BluetoothScannerDevice, + HomeAssistantRemoteScanner, +) from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -71,15 +81,9 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import BluetoothManager +from .manager import MONOTONIC_TIME, HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - HaBluetoothConnector, -) -from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError +from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage if TYPE_CHECKING: @@ -103,7 +107,7 @@ __all__ = [ "async_scanner_count", "async_scanner_devices_by_address", "BaseHaScanner", - "BaseHaRemoteScanner", + "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", "BluetoothChange", "BluetoothServiceInfo", @@ -139,11 +143,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await bluetooth_storage.async_setup() slot_manager = BleakSlotManager() await slot_manager.async_setup() - manager = BluetoothManager( + manager = HomeAssistantBluetoothManager( hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager ) await manager.async_setup() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() + ) hass.data[DATA_MANAGER] = models.MANAGER = manager adapters = await manager.async_get_bluetooth_adapters() @@ -280,8 +286,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE new_info_callback = async_get_advertisement_callback(hass) - manager: BluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(hass, mode, adapter, address, new_info_callback) + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] + scanner = HaScanner(mode, adapter, address, new_info_callback) try: scanner.async_setup() except RuntimeError as err: diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index cdf51d34978..174e5c66ce8 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index a3f5e20a9e9..3a13dda28a8 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_processor import PassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py deleted file mode 100644 index f17bcf938f5..00000000000 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The bluetooth integration advertisement tracker.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.core import callback - -from .models import BluetoothServiceInfoBleak - -ADVERTISING_TIMES_NEEDED = 16 - -# Each scanner may buffer incoming packets so -# we need to give a bit of leeway before we -# mark a device unavailable -TRACKER_BUFFERING_WOBBLE_SECONDS = 5 - - -class AdvertisementTracker: - """Tracker to determine the interval that a device is advertising.""" - - __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") - - def __init__(self) -> None: - """Initialize the tracker.""" - self.intervals: dict[str, float] = {} - self.fallback_intervals: dict[str, float] = {} - self.sources: dict[str, str] = {} - self._timings: dict[str, list[float]] = {} - - @callback - def async_diagnostics(self) -> dict[str, dict[str, Any]]: - """Return diagnostics.""" - return { - "intervals": self.intervals, - "fallback_intervals": self.fallback_intervals, - "sources": self.sources, - "timings": self._timings, - } - - @callback - def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: - """Collect timings for the tracker. - - For performance reasons, it is the responsibility of the - caller to check if the device already has an interval set or - the source has changed before calling this function. - """ - address = service_info.address - self.sources[address] = service_info.source - timings = self._timings.setdefault(address, []) - timings.append(service_info.time) - if len(timings) != ADVERTISING_TIMES_NEEDED: - return - - max_time_between_advertisements = timings[1] - timings[0] - for i in range(2, len(timings)): - time_between_advertisements = timings[i] - timings[i - 1] - if time_between_advertisements > max_time_between_advertisements: - max_time_between_advertisements = time_between_advertisements - - # We now know the maximum time between advertisements - self.intervals[address] = max_time_between_advertisements - del self._timings[address] - - @callback - def async_remove_address(self, address: str) -> None: - """Remove the tracker.""" - self.intervals.pop(address, None) - self.sources.pop(address, None) - self._timings.pop(address, None) - - @callback - def async_remove_fallback_interval(self, address: str) -> None: - """Remove fallback interval.""" - self.fallback_intervals.pop(address, None) - - @callback - def async_remove_source(self, source: str) -> None: - """Remove the tracker.""" - for address, tracked_source in list(self.sources.items()): - if tracked_source == source: - self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9d24428e3d2..afdd26a2001 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,29 +9,25 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from habluetooth import BluetoothScanningMode from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - ProcessAdvertisementCallback, -) +from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback from .wrappers import HaBleakScannerWrapper if TYPE_CHECKING: from bleak.backends.device import BLEDevice -def _get_manager(hass: HomeAssistant) -> BluetoothManager: +def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: """Get the bluetooth manager.""" - return cast(BluetoothManager, hass.data[DATA_MANAGER]) + return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER]) @hass_callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 637ebbaf867..8267a73fd71 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -1,19 +1,14 @@ """Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations -from abc import ABC, abstractmethod -from collections.abc import Callable, Generator -from contextlib import contextmanager +from collections.abc import Callable from dataclasses import dataclass -import datetime -from datetime import timedelta -import logging -from typing import Any, Final +from typing import Any from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from bleak_retry_connector import NO_RSSI_VALUE -from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name +from bluetooth_adapters import DiscoveredDeviceAdvertisementData +from habluetooth import BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -23,20 +18,8 @@ from homeassistant.core import ( HomeAssistant, callback as hass_callback, ) -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util -from homeassistant.util.dt import monotonic_time_coarse from . import models -from .const import ( - CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, -) -from .models import HaBluetoothConnector - -MONOTONIC_TIME: Final = monotonic_time_coarse -_LOGGER = logging.getLogger(__name__) @dataclass(slots=True) @@ -48,150 +31,17 @@ class BluetoothScannerDevice: advertisement: AdvertisementData -class BaseHaScanner(ABC): - """Base class for Ha Scanners.""" +class HomeAssistantRemoteScanner(BaseHaRemoteScanner): + """Home Assistant remote BLE scanner. + + This is the only object that should know about + the hass object. + """ __slots__ = ( "hass", - "adapter", - "connectable", - "source", - "connector", - "_connecting", - "name", - "scanning", - "_last_detection", - "_start_time", - "_cancel_watchdog", - ) - - def __init__( - self, - hass: HomeAssistant, - source: str, - adapter: str, - connector: HaBluetoothConnector | None = None, - ) -> None: - """Initialize the scanner.""" - self.hass = hass - self.connectable = False - self.source = source - self.connector = connector - self._connecting = 0 - self.adapter = adapter - self.name = adapter_human_name(adapter, source) if adapter != source else source - self.scanning = True - self._last_detection = 0.0 - self._start_time = 0.0 - self._cancel_watchdog: CALLBACK_TYPE | None = None - - @hass_callback - def _async_stop_scanner_watchdog(self) -> None: - """Stop the scanner watchdog.""" - if self._cancel_watchdog: - self._cancel_watchdog() - self._cancel_watchdog = None - - @hass_callback - def _async_setup_scanner_watchdog(self) -> None: - """If something has restarted or updated, we need to restart the scanner.""" - self._start_time = self._last_detection = MONOTONIC_TIME() - if not self._cancel_watchdog: - self._cancel_watchdog = async_track_time_interval( - self.hass, - self._async_scanner_watchdog, - SCANNER_WATCHDOG_INTERVAL, - name=f"{self.name} Bluetooth scanner watchdog", - ) - - @hass_callback - def _async_watchdog_triggered(self) -> bool: - """Check if the watchdog has been triggered.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - _LOGGER.debug( - "%s: Scanner watchdog time_since_last_detection: %s", - self.name, - time_since_last_detection, - ) - return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT - - @hass_callback - def _async_scanner_watchdog(self, now: datetime.datetime) -> None: - """Check if the scanner is running. - - Override this method if you need to do something else when the watchdog - is triggered. - """ - if self._async_watchdog_triggered(): - _LOGGER.info( - ( - "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" - " scanner device for more information" - ), - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - self.scanning = False - return - self.scanning = not self._connecting - - @contextmanager - def connecting(self) -> Generator[None, None, None]: - """Context manager to track connecting state.""" - self._connecting += 1 - self.scanning = not self._connecting - try: - yield - finally: - self._connecting -= 1 - self.scanning = not self._connecting - - @property - @abstractmethod - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - - @property - @abstractmethod - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and their advertisement data.""" - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - device_adv_datas = self.discovered_devices_and_advertisement_data.values() - return { - "name": self.name, - "start_time": self._start_time, - "source": self.source, - "scanning": self.scanning, - "type": self.__class__.__name__, - "last_detection": self._last_detection, - "monotonic_time": MONOTONIC_TIME(), - "discovered_devices_and_advertisement_data": [ - { - "name": device.name, - "address": device.address, - "rssi": advertisement_data.rssi, - "advertisement_data": advertisement_data, - "details": device.details, - } - for device, advertisement_data in device_adv_datas - ], - } - - -class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a Home Assistant remote BLE scanner.""" - - __slots__ = ( - "_new_info_callback", - "_discovered_device_advertisement_datas", - "_discovered_device_timestamps", - "_details", - "_expire_seconds", "_storage", + "_cancel_stop", ) def __init__( @@ -204,50 +54,36 @@ class BaseHaRemoteScanner(BaseHaScanner): connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__(hass, scanner_id, name, connector) - self._new_info_callback = new_info_callback - self._discovered_device_advertisement_datas: dict[ - str, tuple[BLEDevice, AdvertisementData] - ] = {} - self._discovered_device_timestamps: dict[str, float] = {} - self.connectable = connectable - self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} - # Scanners only care about connectable devices. The manager - # will handle taking care of availability for non-connectable devices - self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + self.hass = hass assert models.MANAGER is not None self._storage = models.MANAGER.storage + self._cancel_stop: CALLBACK_TYPE | None = None + super().__init__(scanner_id, name, new_info_callback, connector, connectable) @hass_callback def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" + super().async_setup() if history := self._storage.async_get_advertisement_history(self.source): self._discovered_device_advertisement_datas = ( history.discovered_device_advertisement_datas ) self._discovered_device_timestamps = history.discovered_device_timestamps # Expire anything that is too old - self._async_expire_devices(dt_util.utcnow()) + self._async_expire_devices() - cancel_track = async_track_time_interval( - self.hass, - self._async_expire_devices, - timedelta(seconds=30), - name=f"{self.name} Bluetooth scanner device expire", - ) - cancel_stop = self.hass.bus.async_listen( + self._cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history ) - self._async_setup_scanner_watchdog() + return self._unsetup - @hass_callback - def _cancel() -> None: - self._async_save_history() - self._async_stop_scanner_watchdog() - cancel_track() - cancel_stop() - - return _cancel + @hass_callback + def _unsetup(self) -> None: + super()._unsetup() + self._async_save_history() + if self._cancel_stop: + self._cancel_stop() + self._cancel_stop = None @hass_callback def _async_save_history(self, event: Event | None = None) -> None: @@ -262,146 +98,10 @@ class BaseHaRemoteScanner(BaseHaScanner): ), ) - @hass_callback - def _async_expire_devices(self, _datetime: datetime.datetime) -> None: - """Expire old devices.""" - now = MONOTONIC_TIME() - expired = [ - address - for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > self._expire_seconds - ] - for address in expired: - del self._discovered_device_advertisement_datas[address] - del self._discovered_device_timestamps[address] - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - device_adv_datas = self._discovered_device_advertisement_datas.values() - return [ - device_advertisement_data[0] - for device_advertisement_data in device_adv_datas - ] - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self._discovered_device_advertisement_datas - - @hass_callback - def _async_on_advertisement( - self, - address: str, - rssi: int, - local_name: str | None, - service_uuids: list[str], - service_data: dict[str, bytes], - manufacturer_data: dict[int, bytes], - tx_power: int | None, - details: dict[Any, Any], - advertisement_monotonic_time: float, - ) -> None: - """Call the registered callback.""" - self.scanning = not self._connecting - self._last_detection = advertisement_monotonic_time - try: - prev_discovery = self._discovered_device_advertisement_datas[address] - except KeyError: - # We expect this is the rare case and since py3.11+ has - # near zero cost try on success, and we can avoid .get() - # which is slower than [] we use the try/except pattern. - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) - else: - # Merge the new data with the old data - # to function the same as BlueZ which - # merges the dicts on PropertiesChanged - prev_device = prev_discovery[0] - prev_advertisement = prev_discovery[1] - prev_service_uuids = prev_advertisement.service_uuids - prev_service_data = prev_advertisement.service_data - prev_manufacturer_data = prev_advertisement.manufacturer_data - prev_name = prev_device.name - - if prev_name and (not local_name or len(prev_name) > len(local_name)): - local_name = prev_name - - if service_uuids and service_uuids != prev_service_uuids: - service_uuids = list({*service_uuids, *prev_service_uuids}) - elif not service_uuids: - service_uuids = prev_service_uuids - - if service_data and service_data != prev_service_data: - service_data = prev_service_data | service_data - elif not service_data: - service_data = prev_service_data - - if manufacturer_data and manufacturer_data != prev_manufacturer_data: - manufacturer_data = prev_manufacturer_data | manufacturer_data - elif not manufacturer_data: - manufacturer_data = prev_manufacturer_data - # - # Bleak updates the BLEDevice via create_or_update_device. - # We need to do the same to ensure integrations that already - # have the BLEDevice object get the updated details when they - # change. - # - # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 - # - device = prev_device - device.name = local_name - device.details = self._details | details - # pylint: disable-next=protected-access - device._rssi = rssi # deprecated, will be removed in newer bleak - - advertisement_data = AdvertisementData( - local_name=None if local_name == "" else local_name, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, - rssi=rssi, - platform_data=(), - ) - self._discovered_device_advertisement_datas[address] = ( - device, - advertisement_data, - ) - self._discovered_device_timestamps[address] = advertisement_monotonic_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=local_name or address, - address=address, - rssi=rssi, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=self.connectable, - time=advertisement_monotonic_time, - ) - ) - async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" - now = MONOTONIC_TIME() - return await super().async_diagnostics() | { - "storage": self._storage.async_get_advertisement_history_as_dict( - self.source - ), - "connectable": self.connectable, - "discovered_device_timestamps": self._discovered_device_timestamps, - "time_since_last_device_detection": { - address: now - timestamp - for address, timestamp in self._discovered_device_timestamps.items() - }, - } + diag = await super().async_diagnostics() + diag["storage"] = self._storage.async_get_advertisement_history_as_dict( + self.source + ) + return diag diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 150239eec02..fa8efabcb1d 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,9 +1,15 @@ """Constants for the Bluetooth integration.""" from __future__ import annotations -from datetime import timedelta from typing import Final +from habluetooth import ( # noqa: F401 + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, +) + DOMAIN = "bluetooth" CONF_ADAPTER = "adapter" @@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 15 -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker cannot determine the interval. -# -# We have to set this quite high as we don't know -# when devices fall out of the ESPHome device (and other non-local scanners)'s -# stack like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -# -FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 - -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker can determine the interval for -# connectable devices. -# -# BlueZ uses 180 seconds by default but we give it a bit more time -# to account for the esp32's bluetooth stack being a bit slower -# than BlueZ's. -CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 - - -# We must recover before we hit the 180s mark -# where the device is removed from the stack -# or the devices will go unavailable. Since -# we only check every 30s, we need this number -# to be -# 180s Time when device is removed from stack -# - 30s check interval -# - 30s scanner restart time * 2 -# -SCANNER_WATCHDOG_TIMEOUT: Final = 90 -# How often to check if the scanner has reached -# the SCANNER_WATCHDOG_TIMEOUT without seeing anything -SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) - # When the linux kernel is configured with # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index ce047747a0c..777d0ebe317 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable -from datetime import datetime, timedelta import itertools import logging from typing import TYPE_CHECKING, Any, Final @@ -16,6 +15,8 @@ from bluetooth_adapters import ( AdapterDetails, BluetoothAdapters, ) +from bluetooth_data_tools import monotonic_time_coarse +from habluetooth import TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -26,13 +27,7 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.dt import monotonic_time_coarse -from .advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, - AdvertisementTracker, -) from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -103,16 +98,12 @@ class BluetoothManager: """Manage Bluetooth.""" __slots__ = ( - "hass", - "_integration_matcher", "_cancel_unavailable_tracking", - "_cancel_logging_listener", "_advertisement_tracker", "_fallback_intervals", "_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", - "_callback_index", "_bleak_callbacks", "_all_history", "_connectable_history", @@ -125,21 +116,17 @@ class BluetoothManager: "slot_manager", "_debug", "shutdown", + "_loop", ) def __init__( self, - hass: HomeAssistant, - integration_matcher: IntegrationMatcher, bluetooth_adapters: BluetoothAdapters, storage: BluetoothStorage, slot_manager: BleakSlotManager, ) -> None: """Init bluetooth manager.""" - self.hass = hass - self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None - self._cancel_logging_listener: CALLBACK_TYPE | None = None + self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None self._advertisement_tracker = AdvertisementTracker() self._fallback_intervals = self._advertisement_tracker.fallback_intervals @@ -152,7 +139,6 @@ class BluetoothManager: str, list[Callable[[BluetoothServiceInfoBleak], None]] ] = {} - self._callback_index = BluetoothCallbackMatcherIndex() self._bleak_callbacks: list[ tuple[AdvertisementDataCallback, dict[str, set[str]]] ] = [] @@ -167,6 +153,7 @@ class BluetoothManager: self.slot_manager = slot_manager self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self.shutdown = False + self._loop: asyncio.AbstractEventLoop | None = None @property def supports_passive_scan(self) -> bool: @@ -209,7 +196,6 @@ class BluetoothManager: return adapter return None - @hass_callback def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: """Return the scanner for a source.""" return self._sources.get(source) @@ -232,45 +218,22 @@ class BluetoothManager: self._adapters = self._bluetooth_adapters.adapters return self._find_adapter_by_address(address) - @hass_callback - def _async_logging_changed(self, event: Event) -> None: - """Handle logging change.""" - self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - async def async_setup(self) -> None: """Set up the bluetooth manager.""" + self._loop = asyncio.get_running_loop() await self._bluetooth_adapters.refresh() install_multiple_bleak_catcher() - self._all_history, self._connectable_history = async_load_history_from_system( - self._bluetooth_adapters, self.storage - ) - self._cancel_logging_listener = self.hass.bus.async_listen( - EVENT_LOGGING_CHANGED, self._async_logging_changed - ) self.async_setup_unavailable_tracking() - seen: set[str] = set() - for address, service_info in itertools.chain( - self._connectable_history.items(), self._all_history.items() - ): - if address in seen: - continue - seen.add(address) - self._async_trigger_matching_discovery(service_info) - @hass_callback - def async_stop(self, event: Event) -> None: + def async_stop(self) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") self.shutdown = True if self._cancel_unavailable_tracking: - self._cancel_unavailable_tracking() + self._cancel_unavailable_tracking.cancel() self._cancel_unavailable_tracking = None - if self._cancel_logging_listener: - self._cancel_logging_listener() - self._cancel_logging_listener = None uninstall_multiple_bleak_catcher() - @hass_callback def async_scanner_devices_by_address( self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: @@ -291,7 +254,6 @@ class BluetoothManager: ) ] - @hass_callback def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: """Return all of discovered addresses. @@ -307,24 +269,25 @@ class BluetoothManager: for scanner in self._non_connectable_scanners ) - @hass_callback def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: """Return all of combined best path to discovered from all the scanners.""" histories = self._connectable_history if connectable else self._all_history return [history.device for history in histories.values()] - @hass_callback def async_setup_unavailable_tracking(self) -> None: """Set up the unavailable tracking.""" - self._cancel_unavailable_tracking = async_track_time_interval( - self.hass, - self._async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), - name="Bluetooth manager unavailable tracking", + self._schedule_unavailable_tracking() + + def _schedule_unavailable_tracking(self) -> None: + """Schedule the unavailable tracking.""" + if TYPE_CHECKING: + assert self._loop is not None + loop = self._loop + self._cancel_unavailable_tracking = loop.call_at( + loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable ) - @hass_callback - def _async_check_unavailable(self, now: datetime) -> None: + def _async_check_unavailable(self) -> None: """Watch for unavailable devices and cleanup state history.""" monotonic_now = MONOTONIC_TIME() connectable_history = self._connectable_history @@ -366,8 +329,7 @@ class BluetoothManager: # available for both connectable and non-connectable tracker.async_remove_fallback_interval(address) tracker.async_remove_address(address) - self._integration_matcher.async_clear_address(address) - self._async_dismiss_discoveries(address) + self._address_disappeared(address) service_info = history.pop(address) @@ -380,13 +342,13 @@ class BluetoothManager: except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in unavailable callback") - def _async_dismiss_discoveries(self, address: str) -> None: - """Dismiss all discoveries for the given address.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - BluetoothServiceInfoBleak, - lambda service_info: bool(service_info.address == address), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) + self._schedule_unavailable_tracking() + + def _address_disappeared(self, address: str) -> None: + """Call when an address disappears from the stack. + + This method is intended to be overridden by subclasses. + """ def _prefer_previous_adv_from_different_source( self, @@ -439,7 +401,6 @@ class BluetoothManager: return False return True - @hass_callback def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """Handle a new advertisement from any scanner. @@ -570,16 +531,6 @@ class BluetoothManager: time=service_info.time, ) - matched_domains = self._integration_matcher.match_domains(service_info) - if self._debug: - _LOGGER.debug( - "%s: %s %s match: %s", - self._async_describe_source(service_info), - address, - service_info.advertisement, - matched_domains, - ) - if (connectable or old_connectable_service_info) and ( bleak_callbacks := self._bleak_callbacks ): @@ -589,22 +540,14 @@ class BluetoothManager: for callback_filters in bleak_callbacks: _dispatch_bleak_callback(*callback_filters, device, advertisement_data) - for match in self._callback_index.match_callbacks(service_info): - callback = match[CALLBACK] - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") + self._discover_service_info(service_info) - for domain in matched_domains: - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + """Discover a new service info. + + This method is intended to be overridden by subclasses. + """ - @hass_callback def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: """Describe a source.""" if scanner := self._sources.get(service_info.source): @@ -615,7 +558,6 @@ class BluetoothManager: description += " [connectable]" return description - @hass_callback def async_track_unavailable( self, callback: Callable[[BluetoothServiceInfoBleak], None], @@ -629,7 +571,6 @@ class BluetoothManager: unavailable_callbacks = self._unavailable_callbacks unavailable_callbacks.setdefault(address, []).append(callback) - @hass_callback def _async_remove_callback() -> None: unavailable_callbacks[address].remove(callback) if not unavailable_callbacks[address]: @@ -637,50 +578,6 @@ class BluetoothManager: return _async_remove_callback - @hass_callback - def async_register_callback( - self, - callback: BluetoothCallback, - matcher: BluetoothCallbackMatcher | None, - ) -> Callable[[], None]: - """Register a callback.""" - callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) - if not matcher: - callback_matcher[CONNECTABLE] = True - else: - # We could write out every item in the typed dict here - # but that would be a bit inefficient and verbose. - callback_matcher.update(matcher) - callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) - - connectable = callback_matcher[CONNECTABLE] - self._callback_index.add_callback_matcher(callback_matcher) - - @hass_callback - def _async_remove_callback() -> None: - self._callback_index.remove_callback_matcher(callback_matcher) - - # If we have history for the subscriber, we can trigger the callback - # immediately with the last packet so the subscriber can see the - # device. - history = self._connectable_history if connectable else self._all_history - service_infos: Iterable[BluetoothServiceInfoBleak] = [] - if address := callback_matcher.get(ADDRESS): - if service_info := history.get(address): - service_infos = [service_info] - else: - service_infos = history.values() - - for service_info in service_infos: - if ble_device_matches(callback_matcher, service_info): - try: - callback(service_info, BluetoothChange.ADVERTISEMENT) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in bluetooth callback") - - return _async_remove_callback - - @hass_callback def async_ble_device_from_address( self, address: str, connectable: bool ) -> BLEDevice | None: @@ -690,13 +587,11 @@ class BluetoothManager: return history.device return None - @hass_callback def async_address_present(self, address: str, connectable: bool) -> bool: """Return if the address is present.""" histories = self._connectable_history if connectable else self._all_history return address in histories - @hass_callback def async_discovered_service_info( self, connectable: bool ) -> Iterable[BluetoothServiceInfoBleak]: @@ -704,7 +599,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.values() - @hass_callback def async_last_service_info( self, address: str, connectable: bool ) -> BluetoothServiceInfoBleak | None: @@ -712,28 +606,6 @@ class BluetoothManager: histories = self._connectable_history if connectable else self._all_history return histories.get(address) - def _async_trigger_matching_discovery( - self, service_info: BluetoothServiceInfoBleak - ) -> None: - """Trigger discovery for matching domains.""" - for domain in self._integration_matcher.match_domains(service_info): - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, - ) - - @hass_callback - def async_rediscover_address(self, address: str) -> None: - """Trigger discovery of devices which have already been seen.""" - self._integration_matcher.async_clear_address(address) - if service_info := self._connectable_history.get(address): - self._async_trigger_matching_discovery(service_info) - return - if service_info := self._all_history.get(address): - self._async_trigger_matching_discovery(service_info) - def async_register_scanner( self, scanner: BaseHaScanner, @@ -761,7 +633,6 @@ class BluetoothManager: self.slot_manager.register_adapter(scanner.adapter, connection_slots) return _unregister_scanner - @hass_callback def async_register_bleak_callback( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> CALLBACK_TYPE: @@ -769,7 +640,6 @@ class BluetoothManager: callback_entry = (callback, filters) self._bleak_callbacks.append(callback_entry) - @hass_callback def _remove_callback() -> None: self._bleak_callbacks.remove(callback_entry) @@ -783,29 +653,180 @@ class BluetoothManager: return _remove_callback - @hass_callback def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" self.slot_manager.release_slot(device) - @hass_callback def async_allocate_connection_slot(self, device: BLEDevice) -> bool: """Allocate a connection slot.""" return self.slot_manager.allocate_slot(device) - @hass_callback def async_get_learned_advertising_interval(self, address: str) -> float | None: """Get the learned advertising interval for a MAC address.""" return self._intervals.get(address) - @hass_callback def async_get_fallback_availability_interval(self, address: str) -> float | None: """Get the fallback availability timeout for a MAC address.""" return self._fallback_intervals.get(address) - @hass_callback def async_set_fallback_availability_interval( self, address: str, interval: float ) -> None: """Override the fallback availability timeout for a MAC address.""" self._fallback_intervals[address] = interval + + +class HomeAssistantBluetoothManager(BluetoothManager): + """Manage Bluetooth for Home Assistant.""" + + __slots__ = ( + "hass", + "_integration_matcher", + "_callback_index", + "_cancel_logging_listener", + ) + + def __init__( + self, + hass: HomeAssistant, + integration_matcher: IntegrationMatcher, + bluetooth_adapters: BluetoothAdapters, + storage: BluetoothStorage, + slot_manager: BleakSlotManager, + ) -> None: + """Init bluetooth manager.""" + self.hass = hass + self._integration_matcher = integration_matcher + self._callback_index = BluetoothCallbackMatcherIndex() + self._cancel_logging_listener: CALLBACK_TYPE | None = None + super().__init__(bluetooth_adapters, storage, slot_manager) + + @hass_callback + def _async_logging_changed(self, event: Event) -> None: + """Handle logging change.""" + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + + def _async_trigger_matching_discovery( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Trigger discovery for matching domains.""" + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + @hass_callback + def async_rediscover_address(self, address: str) -> None: + """Trigger discovery of devices which have already been seen.""" + self._integration_matcher.async_clear_address(address) + if service_info := self._connectable_history.get(address): + self._async_trigger_matching_discovery(service_info) + return + if service_info := self._all_history.get(address): + self._async_trigger_matching_discovery(service_info) + + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: + matched_domains = self._integration_matcher.match_domains(service_info) + if self._debug: + _LOGGER.debug( + "%s: %s %s match: %s", + self._async_describe_source(service_info), + service_info.address, + service_info.advertisement, + matched_domains, + ) + + for match in self._callback_index.match_callbacks(service_info): + callback = match[CALLBACK] + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + for domain in matched_domains: + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + def _address_disappeared(self, address: str) -> None: + """Dismiss all discoveries for the given address.""" + self._integration_matcher.async_clear_address(address) + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + BluetoothServiceInfoBleak, + lambda service_info: bool(service_info.address == address), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + + async def async_setup(self) -> None: + """Set up the bluetooth manager.""" + await super().async_setup() + self._all_history, self._connectable_history = async_load_history_from_system( + self._bluetooth_adapters, self.storage + ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) + seen: set[str] = set() + for address, service_info in itertools.chain( + self._connectable_history.items(), self._all_history.items() + ): + if address in seen: + continue + seen.add(address) + self._async_trigger_matching_discovery(service_info) + + def async_register_callback( + self, + callback: BluetoothCallback, + matcher: BluetoothCallbackMatcher | None, + ) -> Callable[[], None]: + """Register a callback.""" + callback_matcher = BluetoothCallbackMatcherWithCallback(callback=callback) + if not matcher: + callback_matcher[CONNECTABLE] = True + else: + # We could write out every item in the typed dict here + # but that would be a bit inefficient and verbose. + callback_matcher.update(matcher) + callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) + + connectable = callback_matcher[CONNECTABLE] + self._callback_index.add_callback_matcher(callback_matcher) + + def _async_remove_callback() -> None: + self._callback_index.remove_callback_matcher(callback_matcher) + + # If we have history for the subscriber, we can trigger the callback + # immediately with the last packet so the subscriber can see the + # device. + history = self._connectable_history if connectable else self._all_history + service_infos: Iterable[BluetoothServiceInfoBleak] = [] + if address := callback_matcher.get(ADDRESS): + if service_info := history.get(address): + service_infos = [service_info] + else: + service_infos = history.values() + + for service_info in service_infos: + if ble_device_matches(callback_matcher, service_info): + try: + callback(service_info, BluetoothChange.ADVERTISEMENT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error in bluetooth callback") + + return _async_remove_callback + + @hass_callback + def async_stop(self) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + super().async_stop() + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index c39c28b13f7..5d54ae6ea82 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,8 @@ "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.15.0", - "dbus-fast==2.14.0" + "bluetooth-data-tools==1.17.0", + "dbus-fast==2.20.0", + "habluetooth==0.10.0" ] } diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1856ccd5994..a35c5be6daf 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -2,15 +2,12 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Final -from bleak import BaseBleakClient +from bluetooth_data_tools import monotonic_time_coarse from home_assistant_bluetooth import BluetoothServiceInfoBleak -from homeassistant.util.dt import monotonic_time_coarse - if TYPE_CHECKING: from .manager import BluetoothManager @@ -20,22 +17,6 @@ MANAGER: BluetoothManager | None = None MONOTONIC_TIME: Final = monotonic_time_coarse -@dataclass(slots=True) -class HaBluetoothConnector: - """Data for how to connect a BLEDevice from a given scanner.""" - - client: type[BaseBleakClient] - source: str - can_connect: Callable[[], bool] - - -class BluetoothScanningMode(Enum): - """The mode of scanning for bluetooth devices.""" - - PASSIVE = "passive" - ACTIVE = "active" - - BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") BluetoothCallback = Callable[[BluetoothServiceInfoBleak, BluetoothChange], None] ProcessAdvertisementCallback = Callable[[BluetoothServiceInfoBleak], bool] diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7dd39c14039..8da0d2c462b 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -7,6 +7,8 @@ from functools import cache import logging from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from habluetooth import BluetoothScanningMode + from homeassistant import config_entries from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,11 +35,7 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .models import ( - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - ) + from .models import BluetoothChange, BluetoothServiceInfoBleak STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py deleted file mode 100644 index 896d9dc7958..00000000000 --- a/homeassistant/components/bluetooth/scanner.py +++ /dev/null @@ -1,386 +0,0 @@ -"""The bluetooth integration.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from datetime import datetime -import logging -import platform -from typing import Any - -import bleak -from bleak import BleakError -from bleak.assigned_numbers import AdvertisementDataType -from bleak.backends.bluezdbus.advertisement_monitor import OrPattern -from bleak.backends.bluezdbus.scanner import BlueZScannerArgs -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import restore_discoveries -from bluetooth_adapters import DEFAULT_ADDRESS -from dbus_fast import InvalidMessageError - -from homeassistant.core import HomeAssistant, callback as hass_callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.package import is_docker_env - -from .base_scanner import MONOTONIC_TIME, BaseHaScanner -from .const import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - START_TIMEOUT, -) -from .models import BluetoothScanningMode, BluetoothServiceInfoBleak -from .util import async_reset_adapter - -OriginalBleakScanner = bleak.BleakScanner - -# or_patterns is a workaround for the fact that passive scanning -# needs at least one matcher to be set. The below matcher -# will match all devices. -PASSIVE_SCANNER_ARGS = BlueZScannerArgs( - or_patterns=[ - OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), - OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), - ] -) -_LOGGER = logging.getLogger(__name__) - - -# If the adapter is in a stuck state the following errors are raised: -NEED_RESET_ERRORS = [ - "org.bluez.Error.Failed", - "org.bluez.Error.InProgress", - "org.bluez.Error.NotReady", - "not found", -] - -# When the adapter is still initializing, the scanner will raise an exception -# with org.freedesktop.DBus.Error.UnknownObject -WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] -ADAPTER_INIT_TIME = 1.5 - -START_ATTEMPTS = 3 - -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} - -# The minimum number of seconds to know -# the adapter has not had advertisements -# and we already tried to restart the scanner -# without success when the first time the watch -# dog hit the failure path. -SCANNER_WATCHDOG_MULTIPLE = ( - SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() -) - - -class ScannerStartError(HomeAssistantError): - """Error to indicate that the scanner failed to start.""" - - -def create_bleak_scanner( - detection_callback: AdvertisementDataCallback, - scanning_mode: BluetoothScanningMode, - adapter: str | None, -) -> bleak.BleakScanner: - """Create a Bleak scanner.""" - scanner_kwargs: dict[str, Any] = { - "detection_callback": detection_callback, - "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], - } - system = platform.system() - if system == "Linux": - # Only Linux supports multiple adapters - if adapter: - scanner_kwargs["adapter"] = adapter - if scanning_mode == BluetoothScanningMode.PASSIVE: - scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS - elif system == "Darwin": - # We want mac address on macOS - scanner_kwargs["cb"] = {"use_bdaddr": True} - _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) - - try: - return OriginalBleakScanner(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - - -class HaScanner(BaseHaScanner): - """Operate and automatically recover a BleakScanner. - - Multiple BleakScanner can be used at the same time - if there are multiple adapters. This is only useful - if the adapters are not located physically next to each other. - - Example use cases are usbip, a long extension cable, usb to bluetooth - over ethernet, usb over ethernet, etc. - """ - - scanner: bleak.BleakScanner - - def __init__( - self, - hass: HomeAssistant, - mode: BluetoothScanningMode, - adapter: str, - address: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - ) -> None: - """Init bluetooth discovery.""" - self.mac_address = address - source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(hass, source, adapter) - self.connectable = True - self.mode = mode - self._start_stop_lock = asyncio.Lock() - self._new_info_callback = new_info_callback - self.scanning = False - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - return self.scanner.discovered_devices - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self.scanner.discovered_devices_and_advertisement_data - - @hass_callback - def async_setup(self) -> None: - """Set up the scanner.""" - self.scanner = create_bleak_scanner( - self._async_detection_callback, self.mode, self.adapter - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - base_diag = await super().async_diagnostics() - return base_diag | { - "adapter": self.adapter, - } - - @hass_callback - def _async_detection_callback( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - ) -> None: - """Call the callback when an advertisement is received. - - Currently this is used to feed the callbacks into the - central manager. - """ - callback_time = MONOTONIC_TIME() - if ( - advertisement_data.local_name - or advertisement_data.manufacturer_data - or advertisement_data.service_data - or advertisement_data.service_uuids - ): - # Don't count empty advertisements - # as the adapter is in a failure - # state if all the data is empty. - self._last_detection = callback_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, - rssi=advertisement_data.rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=True, - time=callback_time, - ) - ) - - async def async_start(self) -> None: - """Start bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_start() - - async def _async_start(self) -> None: - """Start bluetooth scanner under the lock.""" - for attempt in range(START_ATTEMPTS): - _LOGGER.debug( - "%s: Starting bluetooth discovery attempt: (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - try: - async with asyncio.timeout(START_TIMEOUT): - await self.scanner.start() # type: ignore[no-untyped-call] - except InvalidMessageError as ex: - _LOGGER.debug( - "%s: Invalid DBus message received: %s", - self.name, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Invalid DBus message received: {ex}; " - "try restarting `dbus`" - ) from ex - except BrokenPipeError as ex: - _LOGGER.debug( - "%s: DBus connection broken: %s", self.name, ex, exc_info=True - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth`, `dbus`, and finally the docker container" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth` and `dbus`" - ) from ex - except FileNotFoundError as ex: - _LOGGER.debug( - "%s: FileNotFoundError while starting bluetooth: %s", - self.name, - ex, - exc_info=True, - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus service not found; docker config may " - "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus service not found; make sure the DBus socket " - f"is available to Home Assistant: {ex}" - ) from ex - except asyncio.TimeoutError as ex: - if attempt == 0: - await self._async_reset_adapter() - continue - raise ScannerStartError( - f"{self.name}: Timed out starting Bluetooth after" - f" {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - error_str = str(ex) - if attempt == 0: - if any( - needs_reset_error in error_str - for needs_reset_error in NEED_RESET_ERRORS - ): - await self._async_reset_adapter() - continue - if attempt != START_ATTEMPTS - 1: - # If we are not out of retry attempts, and the - # adapter is still initializing, wait a bit and try again. - if any( - wait_error in error_str - for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS - ): - _LOGGER.debug( - "%s: Waiting for adapter to initialize; attempt (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - await asyncio.sleep(ADAPTER_INIT_TIME) - continue - - _LOGGER.debug( - "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", - self.name, - attempt + 1, - START_ATTEMPTS, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Failed to start Bluetooth: {ex}" - ) from ex - - # Everything is fine, break out of the loop - break - - self.scanning = True - self._async_setup_scanner_watchdog() - await restore_discoveries(self.scanner, self.adapter) - - @hass_callback - def _async_scanner_watchdog(self, now: datetime) -> None: - """Check if the scanner is running.""" - if not self._async_watchdog_triggered(): - return - if self._start_stop_lock.locked(): - _LOGGER.debug( - "%s: Scanner is already restarting, deferring restart", - self.name, - ) - return - _LOGGER.info( - "%s: Bluetooth scanner has gone quiet for %ss, restarting", - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - # Immediately mark the scanner as not scanning - # since the restart task will have to wait for the lock - self.scanning = False - self.hass.async_create_task(self._async_restart_scanner()) - - async def _async_restart_scanner(self) -> None: - """Restart the scanner.""" - async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - # Stop the scanner but not the watchdog - # since we want to try again later if it's still quiet - await self._async_stop_scanner() - # If there have not been any valid advertisements, - # or the watchdog has hit the failure path multiple times, - # do the reset. - if ( - self._start_time == self._last_detection - or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE - ): - await self._async_reset_adapter() - try: - await self._async_start() - except ScannerStartError as ex: - _LOGGER.exception( - "%s: Failed to restart Bluetooth scanner: %s", - self.name, - ex, - ) - - async def _async_reset_adapter(self) -> None: - """Reset the adapter.""" - # There is currently nothing the user can do to fix this - # so we log at debug level. If we later come up with a repair - # strategy, we will change this to raise a repair issue as well. - _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) - result = await async_reset_adapter(self.adapter, self.mac_address) - _LOGGER.debug("%s: adapter reset result: %s", self.name, result) - - async def async_stop(self) -> None: - """Stop bluetooth scanner.""" - async with self._start_stop_lock: - self._async_stop_scanner_watchdog() - await self._async_stop_scanner() - - async def _async_stop_scanner(self) -> None: - """Stop bluetooth discovery under the lock.""" - self.scanning = False - _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) - try: - await self.scanner.stop() # type: ignore[no-untyped-call] - except BleakError as ex: - # This is not fatal, and they may want to reload - # the config entry to restart the scanner if they - # change the bluetooth dongle. - _LOGGER.error("%s: Error stopping scanner: %s", self.name, ex) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 295e84d4481..2d495a0659c 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -4,6 +4,8 @@ from __future__ import annotations from abc import ABC, abstractmethod import logging +from habluetooth import BluetoothScanningMode + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from .api import ( @@ -13,7 +15,7 @@ from .api import ( async_track_unavailable, ) from .match import BluetoothCallbackMatcher -from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak +from .models import BluetoothChange, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index e78eb51a38c..d531e46f911 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,10 +2,9 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters -from bluetooth_auto_recovery import recover_adapter +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback -from homeassistant.util.dt import monotonic_time_coarse from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage @@ -69,11 +68,3 @@ def async_load_history_from_system( connectable_loaded_history[address] = service_info return all_loaded_history, connectable_loaded_history - - -async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None: - """Reset the adapter.""" - if adapter and adapter.startswith("hci"): - adapter_id = int(adapter[3:]) - return await recover_adapter(adapter_id, mac_address) - return False diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 9de020f163e..e3c08a035a8 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -283,7 +283,6 @@ class HaBleakClientWrapper(BleakClient): self.__disconnected_callback ), timeout=self.__timeout, - hass=manager.hass, ) if debug_logging: # Only lookup the description if we are going to log it diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 911a998371e..854a2f87410 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.3"] + "requirements": ["bimmer-connected[china]==0.14.6"] } diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index bc6235cb219..465c4b8966b 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity @@ -199,10 +199,6 @@ class BondFan(BondEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode != PRESET_MODE_BREEZE or not self._device.has_action( - Action.BREEZE_ON - ): - raise ValueError(f"Invalid preset mode: {preset_mode}") await self._hub.bond.action(self._device.device_id, Action(Action.BREEZE_ON)) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 4c7c224bc44..8986905c6ee 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -12,6 +12,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "The IP address of your Bond hub." } } }, diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 90688e1373f..88eb817bbd9 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -6,6 +6,9 @@ "title": "SHC authentication parameters", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bosch Smart Home Controller." } }, "credentials": { diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index 8f8e728cb9d..4b28fa91d74 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your TV is turned on before trying to set it up.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Sony Bravia TV to control." } }, "authorize": { diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index 87567bcb7b1..335984d1ebe 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -3,10 +3,13 @@ "flow_title": "{name} ({model} at {host})", "step": { "user": { - "title": "Connect to the device", + "description": "Connect to the device", "data": { "host": "[%key:common::config_flow::data::host%]", "timeout": "Timeout" + }, + "data_description": { + "host": "The hostname or IP address of your Broadlink device." } }, "auth": { diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index e24c941c514..0d8f4f4eedf 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "type": "Type of the printer" + }, + "data_description": { + "host": "The hostname or IP address of the Brother printer to control." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 39eab6e7e0a..609d5ab6e83 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -60,8 +60,7 @@ async def async_setup_entry( data.static, entry, ) - ], - True, + ] ) diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 0693f3fb8ea..689d1f893d3 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -11,6 +11,9 @@ "passkey": "Passkey string", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your BSB-Lan device." } } }, diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index f9236049048..fa89d6acc38 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -11,7 +11,11 @@ async def async_get_calendars( hass: HomeAssistant, client: caldav.DAVClient, component: str ) -> list[caldav.Calendar]: """Get all calendars that support the specified component.""" - calendars = await hass.async_add_executor_job(client.principal().calendars) + + def _get_calendars() -> list[caldav.Calendar]: + return client.principal().calendars() + + calendars = await hass.async_add_executor_job(_get_calendars) components_results = await asyncio.gather( *[ hass.async_add_executor_job(calendar.get_supported_components) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index eddfe410100..b7089c3da65 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -2,10 +2,10 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import date, datetime, timedelta from functools import partial import logging -from typing import cast +from typing import Any, cast import caldav from caldav.lib.error import DAVError, NotFoundError @@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from .api import async_get_calendars, get_attr_value from .const import DOMAIN @@ -71,6 +72,12 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: or (summary := get_attr_value(todo, "summary")) is None ): return None + due: date | datetime | None = None + if due_value := get_attr_value(todo, "due"): + if isinstance(due_value, datetime): + due = dt_util.as_local(due_value) + elif isinstance(due_value, date): + due = due_value return TodoItem( uid=uid, summary=summary, @@ -78,9 +85,25 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: get_attr_value(todo, "status") or "", TodoItemStatus.NEEDS_ACTION, ), + due=due, + description=get_attr_value(todo, "description"), ) +def _to_ics_fields(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["summary"] = summary + if status := item.status: + item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + item_data["due"] = due + if description := item.description: + item_data["description"] = description + return item_data + + class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" @@ -89,6 +112,9 @@ class WebDavTodoListEntity(TodoListEntity): TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__(self, calendar: caldav.Calendar, config_entry_id: str) -> None: @@ -116,13 +142,7 @@ class WebDavTodoListEntity(TodoListEntity): """Add an item to the To-do list.""" try: await self.hass.async_add_executor_job( - partial( - self._calendar.save_todo, - summary=item.summary, - status=TODO_STATUS_MAP_INV.get( - item.status or TodoItemStatus.NEEDS_ACTION, "NEEDS-ACTION" - ), - ), + partial(self._calendar.save_todo, **_to_ics_fields(item)), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -139,10 +159,10 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - if item.summary: - vtodo["summary"] = item.summary - if item.status: - vtodo["status"] = TODO_STATUS_MAP_INV.get(item.status, "NEEDS-ACTION") + updated_fields = _to_ics_fields(item) + if "due" in updated_fields: + todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] + vtodo.update(**updated_fields) try: await self.hass.async_add_executor_job( partial( diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 57450000199..78b8407240c 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -73,7 +73,7 @@ } }, "get_events": { - "name": "Get event", + "name": "Get events", "description": "Get events on a calendar within a time range.", "fields": { "start_date_time": { diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index d82af5b5034..f91232c1a28 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,7 +1,7 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": ["@jpbede"], + "codeowners": ["@jpbede", "@VIKTORVAV99"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", "integration_type": "service", diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index d3bc973429b..1573d5cb627 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -68,13 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + try: await self.api.login() + return await self.api.get_all_devices() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed - - return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 77796ac7e7f..89157b54255 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.5.2"] + "requirements": ["aiocomelit==0.6.2"] } diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 730674e913a..73c2c7d00c6 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "host": "The hostname or IP address of your Comelit device." } } }, diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 3f00a9b59f0..f76ed5939f5 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 99ebb4b60b1..aae8f67e1d8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -649,7 +649,7 @@ class DefaultAgent(AbstractConversationAgent): if device_area is None: return None - return {"area": device_area.name} + return {"area": device_area.id} def _get_error_text( self, response_type: ResponseType, lang_intents: LanguageIntents | None diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index e99334b5c37..cb03499d8e4 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.11.17"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2023.12.05"] } diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json index 7baa6444c1d..17deab306df 100644 --- a/homeassistant/components/coolmaster/strings.json +++ b/homeassistant/components/coolmaster/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Set up your CoolMasterNet connection details.", + "description": "Set up your CoolMasterNet connection details.", "data": { "host": "[%key:common::config_flow::data::host%]", "off": "Can be turned off", @@ -12,6 +12,9 @@ "dry": "Support dry mode", "fan_only": "Support fan only mode", "swing_support": "Control swing mode" + }, + "data_description": { + "host": "The hostname or IP address of your CoolMasterNet device." } } }, diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index dc2ed04b4ed..044c9bf203b 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -67,7 +67,7 @@ DECONZ_TO_COLOR_MODE = { LightColorMode.XY: ColorMode.XY, } -TS0601_EFFECTS = [ +XMAS_LIGHT_EFFECTS = [ "carnival", "collide", "fading", @@ -200,8 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] - if device.model_id == "TS0601": - self._attr_effect_list += TS0601_EFFECTS + if device.model_id in ("HG06467", "TS0601"): + self._attr_effect_list = XMAS_LIGHT_EFFECTS @property def color_mode(self) -> str | None: diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index e32ab875c28..c06a07e6ce5 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -11,11 +11,14 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your deCONZ host." } }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json index e0266d004e2..52706f39894 100644 --- a/homeassistant/components/deluge/strings.json +++ b/homeassistant/components/deluge/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", "web_port": "Web port (for visiting service)" + }, + "data_description": { + "host": "The hostname or IP address of your Deluge device." } } }, diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 211389a5466..73cae4a64b1 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -161,12 +161,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes and preset_mode in self.preset_modes: - self._preset_mode = preset_mode - self._percentage = None - self.schedule_update_ha_state() - else: - raise ValueError(f"Invalid preset mode: {preset_mode}") + self._preset_mode = preset_mode + self._percentage = None + self.schedule_update_ha_state() def turn_on( self, @@ -230,10 +227,6 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.preset_modes is None or preset_mode not in self.preset_modes: - raise ValueError( - f"{preset_mode} is not a valid preset_mode: {self.preset_modes}" - ) self._preset_mode = preset_mode self._percentage = None self.async_write_ha_state() diff --git a/homeassistant/components/devialet/coordinator.py b/homeassistant/components/devialet/coordinator.py index f0ee47150cc..9e1eada7183 100644 --- a/homeassistant/components/devialet/coordinator.py +++ b/homeassistant/components/devialet/coordinator.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -class DevialetCoordinator(DataUpdateCoordinator): +class DevialetCoordinator(DataUpdateCoordinator[None]): """Devialet update coordinator.""" def __init__(self, hass: HomeAssistant, client: DevialetApi) -> None: @@ -27,6 +27,6 @@ class DevialetCoordinator(DataUpdateCoordinator): ) self.client = client - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" await self.client.async_update() diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index 75fc420fa87..a79a82e6f60 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -46,13 +46,15 @@ async def async_setup_entry( async_add_entities([DevialetMediaPlayerEntity(coordinator, entry)]) -class DevialetMediaPlayerEntity(CoordinatorEntity, MediaPlayerEntity): +class DevialetMediaPlayerEntity( + CoordinatorEntity[DevialetCoordinator], MediaPlayerEntity +): """Devialet media player.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator, entry: ConfigEntry) -> None: + def __init__(self, coordinator: DevialetCoordinator, entry: ConfigEntry) -> None: """Initialize the Devialet device.""" self.coordinator = coordinator super().__init__(coordinator) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index f18f7984e1e..a17972526cf 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant import util from homeassistant.backports.functools import cached_property from homeassistant.components import zone -from homeassistant.config import async_log_schema_error, load_yaml_config_file +from homeassistant.config import ( + async_log_schema_error, + config_per_platform, + load_yaml_config_file, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -33,7 +37,6 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( - config_per_platform, config_validation as cv, discovery, entity_registry as er, @@ -284,7 +287,7 @@ class DeviceTrackerPlatform: ) -> None: """Set up a legacy platform.""" assert self.type == PLATFORM_TYPE_LEGACY - full_name = f"{DOMAIN}.{self.name}" + full_name = f"{self.name}.{DOMAIN}" LOGGER.info("Setting up %s", full_name) with async_start_setup(hass, [full_name]): try: @@ -1033,6 +1036,19 @@ def update_config(path: str, dev_id: str, device: Device) -> None: out.write(dump(device_config)) +def remove_device_from_config(hass: HomeAssistant, device_id: str) -> None: + """Remove device from YAML configuration file.""" + path = hass.config.path(YAML_DEVICES) + devices = load_yaml_config_file(path) + devices.pop(device_id) + dumped = dump(devices) + + with open(path, "r+", encoding="utf8") as out: + out.seek(0) + out.truncate() + out.write(dumped) + + def get_gravatar_for_email(email: str) -> str: """Return an 80px Gravatar for the given email address. diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 8ed52cd3632..2c30e3db85c 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -8,6 +8,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your DirectTV device." } } }, diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index ed878fbb82e..9648492c2e4 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -183,6 +183,7 @@ async def async_setup_entry( for description in sensors for value_key in {description.key, *description.alternative_keys} if description.value_fn(coordinator.data, value_key, description.scale) + is not None ) async_add_entities(entities) diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 8c60d59fa6b..9f21a9571e9 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -9,6 +9,7 @@ "use_legacy_protocol": "Use legacy protocol" }, "data_description": { + "host": "The hostname or IP address of your D-Link device", "password": "Default: PIN code on the back." } }, diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index ceaf1a891ee..c851de379d4 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -17,8 +17,11 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "host": "[%key:common::config_flow::data::host%]", - "name": "Device Name", + "name": "Device name", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your DoorBird device." } } }, diff --git a/homeassistant/components/dremel_3d_printer/strings.json b/homeassistant/components/dremel_3d_printer/strings.json index 0016b8f2bca..9f6870b57f6 100644 --- a/homeassistant/components/dremel_3d_printer/strings.json +++ b/homeassistant/components/dremel_3d_printer/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dremel 3D printer." } } }, diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 3b32d354766..86a7bee9ef1 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -116,7 +116,7 @@ class DSMRConnection: try: transport, protocol = await asyncio.create_task(reader_factory()) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): LOGGER.exception("Error connecting to DSMR") return False diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 5e1a54aedc4..4ac59372deb 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -12,8 +12,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" CONF_PROTOCOL = "protocol" -CONF_RECONNECT_INTERVAL = "reconnect_interval" -CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" CONF_SERIAL_ID = "serial_id" @@ -29,6 +27,7 @@ DATA_TASK = "task" DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_GAS = "Gas Meter" +DEVICE_NAME_WATER = "Water Meter" DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 3dbd446001f..6aadcd63d44 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -34,6 +34,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -45,9 +46,7 @@ from homeassistant.util import Throttle from .const import ( CONF_DSMR_VERSION, - CONF_PRECISION, CONF_PROTOCOL, - CONF_RECONNECT_INTERVAL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -57,6 +56,7 @@ from .const import ( DEFAULT_TIME_BETWEEN_UPDATE, DEVICE_NAME_ELECTRICITY, DEVICE_NAME_GAS, + DEVICE_NAME_WATER, DOMAIN, DSMR_PROTOCOL, LOGGER, @@ -73,10 +73,18 @@ class DSMRSensorEntityDescription(SensorEntityDescription): dsmr_versions: set[str] | None = None is_gas: bool = False + is_water: bool = False obis_reference: str SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( + DSMRSensorEntityDescription( + key="timestamp", + obis_reference=obis_references.P1_MESSAGE_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), DSMRSensorEntityDescription( key="current_electricity_usage", translation_key="current_electricity_usage", @@ -374,28 +382,138 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ) -def add_gas_sensor_5B(telegram: dict[str, DSMRObject]) -> DSMRSensorEntityDescription: - """Return correct entity for 5B Gas meter.""" - ref = None - if obis_references.BELGIUM_MBUS1_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - elif obis_references.BELGIUM_MBUS2_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS2_METER_READING2 - elif obis_references.BELGIUM_MBUS3_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS3_METER_READING2 - elif obis_references.BELGIUM_MBUS4_METER_READING2 in telegram: - ref = obis_references.BELGIUM_MBUS4_METER_READING2 - elif ref is None: - ref = obis_references.BELGIUM_MBUS1_METER_READING2 - return DSMRSensorEntityDescription( - key="belgium_5min_gas_meter_reading", - translation_key="gas_meter_reading", - obis_reference=ref, - dsmr_versions={"5B"}, - is_gas=True, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL_INCREASING, - ) +def create_mbus_entity( + mbus: int, mtype: int, telegram: dict[str, DSMRObject] +) -> DSMRSensorEntityDescription | None: + """Create a new MBUS Entity.""" + if ( + mtype == 3 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING2" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_gas_reading", + translation_key="gas_meter_reading", + obis_reference=obis_reference, + is_gas=True, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + if ( + mtype == 7 + and ( + obis_reference := getattr( + obis_references, f"BELGIUM_MBUS{mbus}_METER_READING1" + ) + ) + in telegram + ): + return DSMRSensorEntityDescription( + key=f"mbus{mbus}_water_reading", + translation_key="water_meter_reading", + obis_reference=obis_reference, + is_water=True, + device_class=SensorDeviceClass.WATER, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + return None + + +def device_class_and_uom( + telegram: dict[str, DSMRObject], + entity_description: DSMRSensorEntityDescription, +) -> tuple[SensorDeviceClass | None, str | None]: + """Get native unit of measurement from telegram,.""" + dsmr_object = telegram[entity_description.obis_reference] + uom: str | None = getattr(dsmr_object, "unit") or None + with suppress(ValueError): + if entity_description.device_class == SensorDeviceClass.GAS and ( + enery_uom := UnitOfEnergy(str(uom)) + ): + return (SensorDeviceClass.ENERGY, enery_uom) + if uom in UNIT_CONVERSION: + return (entity_description.device_class, UNIT_CONVERSION[uom]) + return (entity_description.device_class, uom) + + +def rename_old_gas_to_mbus( + hass: HomeAssistant, entry: ConfigEntry, mbus_device_id: str +) -> None: + """Rename old gas sensor to mbus variant.""" + dev_reg = dr.async_get(hass) + device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + if device_entry_v1 is not None: + device_id = device_entry_v1.id + + ent_reg = er.async_get(hass) + entries = er.async_entries_for_device(ent_reg, device_id) + + for entity in entries: + if entity.unique_id.endswith("belgium_5min_gas_meter_reading"): + try: + ent_reg.async_update_entity( + entity.entity_id, + new_unique_id=mbus_device_id, + device_id=mbus_device_id, + ) + except ValueError: + LOGGER.debug( + "Skip migration of %s because it already exists", + entity.entity_id, + ) + else: + LOGGER.debug( + "Migrated entity %s from unique id %s to %s", + entity.entity_id, + entity.unique_id, + mbus_device_id, + ) + # Cleanup old device + dev_entities = er.async_entries_for_device( + ent_reg, device_id, include_disabled_entities=True + ) + if not dev_entities: + dev_reg.async_remove_device(device_id) + + +def create_mbus_entities( + hass: HomeAssistant, telegram: dict[str, DSMRObject], entry: ConfigEntry +) -> list[DSMREntity]: + """Create MBUS Entities.""" + entities = [] + for idx in range(1, 5): + if ( + device_type := getattr(obis_references, f"BELGIUM_MBUS{idx}_DEVICE_TYPE") + ) not in telegram: + continue + if (type_ := int(telegram[device_type].value)) not in (3, 7): + continue + if ( + identifier := getattr( + obis_references, + f"BELGIUM_MBUS{idx}_EQUIPMENT_IDENTIFIER", + ) + ) in telegram: + serial_ = telegram[identifier].value + rename_old_gas_to_mbus(hass, entry, serial_) + else: + serial_ = "" + if description := create_mbus_entity(idx, type_, telegram): + entities.append( + DSMREntity( + description, + entry, + telegram, + *device_class_and_uom(telegram, description), # type: ignore[arg-type] + serial_, + idx, + ) + ) + return entities async def async_setup_entry( @@ -415,25 +533,10 @@ async def async_setup_entry( add_entities_handler() add_entities_handler = None - def device_class_and_uom( - telegram: dict[str, DSMRObject], - entity_description: DSMRSensorEntityDescription, - ) -> tuple[SensorDeviceClass | None, str | None]: - """Get native unit of measurement from telegram,.""" - dsmr_object = telegram[entity_description.obis_reference] - uom: str | None = getattr(dsmr_object, "unit") or None - with suppress(ValueError): - if entity_description.device_class == SensorDeviceClass.GAS and ( - enery_uom := UnitOfEnergy(str(uom)) - ): - return (SensorDeviceClass.ENERGY, enery_uom) - if uom in UNIT_CONVERSION: - return (entity_description.device_class, UNIT_CONVERSION[uom]) - return (entity_description.device_class, uom) - - all_sensors = SENSORS if dsmr_version == "5B": - all_sensors += (add_gas_sensor_5B(telegram),) + mbus_entities = create_mbus_entities(hass, telegram, entry) + for mbus_entity in mbus_entities: + entities.append(mbus_entity) entities.extend( [ @@ -443,7 +546,7 @@ async def async_setup_entry( telegram, *device_class_and_uom(telegram, description), # type: ignore[arg-type] ) - for description in all_sensors + for description in SENSORS if ( description.dsmr_versions is None or dsmr_version in description.dsmr_versions @@ -549,11 +652,9 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): # Log any error while establishing connection and drop to retry # connection wait LOGGER.exception("Error connecting to DSMR") @@ -565,9 +666,7 @@ async def async_setup_entry( update_entities_telegram(None) # throttle reconnect attempts - await asyncio.sleep( - entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) - ) + await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) except CancelledError: # Reflect disconnect state in devices state by setting an # None telegram resulting in `unavailable` states @@ -618,6 +717,8 @@ class DSMREntity(SensorEntity): telegram: dict[str, DSMRObject], device_class: SensorDeviceClass, native_unit_of_measurement: str | None, + serial_id: str = "", + mbus_id: int = 0, ) -> None: """Initialize entity.""" self.entity_description = entity_description @@ -629,8 +730,15 @@ class DSMREntity(SensorEntity): device_serial = entry.data[CONF_SERIAL_ID] device_name = DEVICE_NAME_ELECTRICITY if entity_description.is_gas: - device_serial = entry.data[CONF_SERIAL_ID_GAS] + if serial_id: + device_serial = serial_id + else: + device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS + if entity_description.is_water: + if serial_id: + device_serial = serial_id + device_name = DEVICE_NAME_WATER if device_serial is None: device_serial = entry.entry_id @@ -638,7 +746,13 @@ class DSMREntity(SensorEntity): identifiers={(DOMAIN, device_serial)}, name=device_name, ) - self._attr_unique_id = f"{device_serial}_{entity_description.key}" + if mbus_id != 0: + if serial_id: + self._attr_unique_id = f"{device_serial}" + else: + self._attr_unique_id = f"{device_serial}_{mbus_id}" + else: + self._attr_unique_id = f"{device_serial}_{entity_description.key}" @callback def update_data(self, telegram: dict[str, DSMRObject] | None) -> None: @@ -682,9 +796,11 @@ class DSMREntity(SensorEntity): return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): - value = round( - float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) - ) + value = round(float(value), DEFAULT_PRECISION) + + # Make sure we do not return a zero value for an energy sensor + if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: + return None return value diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5f0568e2905..055c0c41264 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -147,6 +147,9 @@ }, "voltage_swell_l3_count": { "name": "Voltage swells phase L3" + }, + "water_meter_reading": { + "name": "Water consumption" } } }, diff --git a/homeassistant/components/dunehd/strings.json b/homeassistant/components/dunehd/strings.json index f7e12b39f16..7d60a720a98 100644 --- a/homeassistant/components/dunehd/strings.json +++ b/homeassistant/components/dunehd/strings.json @@ -5,6 +5,9 @@ "description": "Ensure that your player is turned on.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Dune HD device." } } }, diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index 93a545d31dc..a5585c3dd2c 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Duotecno device." } } }, diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6fa177c7221..6f57ea6ed5f 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==1.0.0"] + "requirements": ["easyenergy==2.1.0"] } diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index d1767be5cda..1094e10ada3 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ecoforest device." } } }, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index e6b16215793..6e1031c8ddf 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Elgato device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/emonitor/strings.json b/homeassistant/components/emonitor/strings.json index 675db107935..08ffe030890 100644 --- a/homeassistant/components/emonitor/strings.json +++ b/homeassistant/components/emonitor/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your SiteSage Emonitor device." } }, "confirm": { diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index a98d2c08a48..1ba93da716c 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,7 +6,6 @@ import logging from aiohttp import web import voluptuous as vol -from homeassistant.components.http import HomeAssistantAccessLogger from homeassistant.components.network import async_get_source_ip from homeassistant.const import ( CONF_ENTITIES, @@ -101,7 +100,7 @@ async def start_emulated_hue_bridge( config.advertise_port or config.listen_port, ) - runner = web.AppRunner(app, access_log_class=HomeAssistantAccessLogger) + runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index e9760a96aa4..834a9bbb1eb 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -317,6 +317,11 @@ class EnergyCostSensor(SensorEntity): try: energy_price = float(energy_price_state.state) except ValueError: + if self._last_energy_sensor_state is None: + # Initialize as it's the first time all required entities except + # price are in place. This means that the cost will update the first + # time the energy is updated after the price entity is in place. + self._reset(energy_state) return energy_price_unit: str | None = energy_price_state.attributes.get( diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 9ef99173ffb..025f929a4f6 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==1.0.0"] + "requirements": ["energyzero==2.1.0"] } diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 94cf9233745..fe32002e6b2 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Enphase Envoy gateway." } } }, diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 4e3780322e9..94544c32d1d 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Epson projector." } } }, diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 96f1bce686a..06282749649 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from functools import partial import logging import sys -from typing import Any, TypeVar, cast +from typing import Any, Concatenate, ParamSpec, TypeVar import uuid if sys.version_info < (3, 12): @@ -60,7 +60,9 @@ CCCD_INDICATE_BYTES = b"\x02\x00" DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) -_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) +_ESPHomeClient = TypeVar("_ESPHomeClient", bound="ESPHomeClient") +_R = TypeVar("_R") +_P = ParamSpec("_P") def mac_to_int(address: str) -> int: @@ -68,12 +70,14 @@ def mac_to_int(address: str) -> int: return int(address.replace(":", ""), 16) -def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: +def api_error_as_bleak_error( + func: Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]] +) -> Callable[Concatenate[_ESPHomeClient, _P], Coroutine[Any, Any, _R]]: """Define a wrapper throw esphome api errors as BleakErrors.""" async def _async_wrap_bluetooth_operation( - self: ESPHomeClient, *args: Any, **kwargs: Any - ) -> Any: + self: _ESPHomeClient, *args: _P.args, **kwargs: _P.kwargs + ) -> _R: # pylint: disable=protected-access try: return await func(self, *args, **kwargs) @@ -107,7 +111,7 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: except APIConnectionError as err: raise BleakError(str(err)) from err - return cast(_WrapFuncType, _async_wrap_bluetooth_operation) + return _async_wrap_bluetooth_operation @dataclass(slots=True) diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index a54e7af59a6..b4fb12210d3 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -7,11 +7,14 @@ from bluetooth_data_tools import ( parse_advertisement_data_tuple, ) -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback -class ESPHomeScanner(BaseHaRemoteScanner): +class ESPHomeScanner(HomeAssistantRemoteScanner): """Scanner for esphome.""" __slots__ = () diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 73b326204b5..08ed2f1109d 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -173,8 +173,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti features |= ClimateEntityFeature.TARGET_TEMPERATURE if self._static_info.supports_target_humidity: features |= ClimateEntityFeature.TARGET_HUMIDITY - if self._static_info.supports_aux_heat: - features |= ClimateEntityFeature.AUX_HEAT if self.preset_modes: features |= ClimateEntityFeature.PRESET_MODE if self.fan_modes: @@ -272,12 +270,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """Return the humidity we try to reach.""" return round(self._state.target_humidity) - @property - @esphome_state_property - def is_aux_heat(self) -> bool: - """Return the auxiliary heater state.""" - return self._state.aux_heat - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature (and operation mode if set).""" data: dict[str, Any] = {"key": self._key} @@ -326,11 +318,3 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti await self._client.climate_command( key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) ) - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self._client.climate_command(key=self._key, aux_heat=True) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self._client.climate_command(key=self._key, aux_heat=False) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 9942498e12d..08135e1a702 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -105,6 +105,10 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._client.fan_command(key=self._key, preset_mode=preset_mode) + @property @esphome_state_property def is_on(self) -> bool | None: @@ -144,6 +148,17 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current fan direction.""" return _FAN_DIRECTIONS.from_esphome(self._state.direction) + @property + @esphome_state_property + def preset_mode(self) -> str | None: + """Return the current fan preset mode.""" + return self._state.preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return the supported fan preset modes.""" + return self._static_info.supported_preset_modes + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" @@ -156,4 +171,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): flags |= FanEntityFeature.SET_SPEED if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION + if static_info.supported_preset_modes: + flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 26d15da680b..e0b47f09d95 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,8 +15,8 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==19.2.0", - "bluetooth-data-tools==1.15.0", + "aioesphomeapi==19.3.0", + "bluetooth-data-tools==1.17.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 68ed98aa789..de6b521d980 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -186,16 +186,22 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = {"text": event.data["tts_input"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert event.data is not None - path = event.data["tts_output"]["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} + tts_output = event.data["tts_output"] + if tts_output: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} - if self.device_info.voice_assistant_version >= 2: - media_id = event.data["tts_output"]["media_id"] - self._tts_task = self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) + if self.device_info.voice_assistant_version >= 2: + media_id = tts_output["media_id"] + self._tts_task = self.hass.async_create_background_task( + self._send_tts(media_id), "esphome_voice_assistant_tts" + ) + else: + self._tts_done.set() else: + # Empty TTS response + data_to_send = {} self._tts_done.set() elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: assert event.data is not None diff --git a/homeassistant/components/evil_genius_labs/strings.json b/homeassistant/components/evil_genius_labs/strings.json index 790e9a69c7f..123d164444d 100644 --- a/homeassistant/components/evil_genius_labs/strings.json +++ b/homeassistant/components/evil_genius_labs/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Evil Genius Labs device." } } }, diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index f4ceaf2c48c..9c33b0fbf31 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -190,14 +190,14 @@ def _handle_exception(err) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Create a (EMEA/EU-based) Honeywell TCC system.""" - async def load_auth_tokens(store) -> tuple[dict, dict | None]: + async def load_auth_tokens(store) -> tuple[dict[str, str | dt], dict[str, str]]: app_storage = await store.async_load() tokens = dict(app_storage or {}) if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: # any tokens won't be valid, and store might be corrupt await store.async_save({}) - return ({}, None) + return ({}, {}) # evohomeasync2 requires naive/local datetimes as strings if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and ( @@ -205,7 +205,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ): tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(expires) - user_data = tokens.pop(USER_DATA, None) + user_data = tokens.pop(USER_DATA, {}) return (tokens, user_data) store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) @@ -214,7 +214,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client_v2 = evohomeasync2.EvohomeClient( config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], - **tokens, + **tokens, # type: ignore[arg-type] session=async_get_clientsession(hass), ) @@ -253,7 +253,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client_v1 = evohomeasync.EvohomeClient( client_v2.username, client_v2.password, - user_data=user_data, + session_id=user_data.get("sessionId") if user_data else None, # STORAGE_VER 1 session=async_get_clientsession(hass), ) @@ -425,7 +425,7 @@ class EvoBroker: self.tcs_utc_offset = timedelta( minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] ) - self.temps: dict[str, Any] | None = {} + self.temps: dict[str, float | None] = {} async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" @@ -441,14 +441,12 @@ class EvoBroker: ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), } - if self.client_v1 and self.client_v1.user_data: - user_id = self.client_v1.user_data["userInfo"]["userID"] # type: ignore[index] + if self.client_v1: app_storage[USER_DATA] = { # type: ignore[assignment] - "userInfo": {"userID": user_id}, - "sessionId": self.client_v1.user_data["sessionId"], - } + "sessionId": self.client_v1.broker.session_id, + } # this is the schema for STORAGE_VER == 1 else: - app_storage[USER_DATA] = None + app_storage[USER_DATA] = {} # type: ignore[assignment] await self._store.async_save(app_storage) @@ -468,16 +466,13 @@ class EvoBroker: async def _update_v1_api_temps(self, *args, **kwargs) -> None: """Get the latest high-precision temperatures of the default Location.""" - assert self.client_v1 + assert self.client_v1 # mypy check - def get_session_id(client_v1) -> str | None: - user_data = client_v1.user_data if client_v1 else None - return user_data.get("sessionId") if user_data else None - - session_id = get_session_id(self.client_v1) + session_id = self.client_v1.broker.session_id # maybe receive a new session_id? + self.temps = {} # these are now stale, will fall back to v2 temps try: - temps = list(await self.client_v1.temperatures(force_refresh=True)) + temps = await self.client_v1.get_temperatures() except evohomeasync.InvalidSchema as exc: _LOGGER.warning( @@ -489,7 +484,7 @@ class EvoBroker: ), exc, ) - self.temps = self.client_v1 = None + self.client_v1 = None except evohomeasync.EvohomeError as exc: _LOGGER.warning( @@ -501,7 +496,6 @@ class EvoBroker: ), exc, ) - self.temps = None # these are now stale, will fall back to v2 temps else: if ( @@ -513,19 +507,20 @@ class EvoBroker: "the v1 API's default location (there is more than one location), " "so the high-precision feature will be disabled until next restart" ) - self.temps = self.client_v1 = None + self.client_v1 = None else: self.temps = {str(i["id"]): i["temp"] for i in temps} finally: - if session_id != get_session_id(self.client_v1): + if self.client_v1 and session_id != self.client_v1.broker.session_id: await self.save_auth_tokens() _LOGGER.debug("Temperatures = %s", self.temps) async def _update_v2_api_state(self, *args, **kwargs) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token + + access_token = self.client.access_token # maybe receive a new token? loc_idx = self.params[CONF_LOCATION_IDX] try: @@ -536,9 +531,9 @@ class EvoBroker: async_dispatcher_send(self.hass, DOMAIN) _LOGGER.debug("Status = %s", status) - - if access_token != self.client.access_token: - await self.save_auth_tokens() + finally: + if access_token != self.client.access_token: + await self.save_auth_tokens() async def async_update(self, *args, **kwargs) -> None: """Get the latest state data of an entire Honeywell TCC Location. @@ -562,6 +557,8 @@ class EvoDevice(Entity): _attr_should_poll = False + _evo_id: str + def __init__(self, evo_broker, evo_device) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device @@ -623,18 +620,10 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" - if self._evo_device.TYPE == "domesticHotWater": - dev_id = self._evo_device.dhwId - else: - dev_id = self._evo_device.zoneId - if self._evo_broker.temps and self._evo_broker.temps[dev_id] is not None: - return self._evo_broker.temps[dev_id] - - if self._evo_device.temperatureStatus["isAvailable"]: - return self._evo_device.temperatureStatus["temperature"] - - return None + if self._evo_broker.temps.get(self._evo_id) is not None: + return self._evo_broker.temps[self._evo_id] + return self._evo_device.temperature @property def setpoints(self) -> dict[str, Any]: @@ -679,7 +668,7 @@ class EvoChild(EvoDevice): switchpoint_time_of_day = dt_util.parse_datetime( f"{sp_date}T{switchpoint['TimeOfDay']}" ) - assert switchpoint_time_of_day + assert switchpoint_time_of_day # mypy check dt_aware = _dt_evo_to_aware( switchpoint_time_of_day, self._evo_broker.tcs_utc_offset ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index fb608262a7d..dea5676d332 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -150,6 +150,7 @@ class EvoZone(EvoChild, EvoClimateEntity): self._attr_unique_id = f"{evo_device.zoneId}z" else: self._attr_unique_id = evo_device.zoneId + self._evo_id = evo_device.zoneId self._attr_name = evo_device.name @@ -189,24 +190,27 @@ class EvoZone(EvoChild, EvoClimateEntity): ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): return HVACMode.AUTO - is_off = self.target_temperature <= self.min_temp - return HVACMode.OFF if is_off else HVACMode.HEAT + if self.target_temperature is None: + return None + if self.target_temperature <= self.min_temp: + return HVACMode.OFF + return HVACMode.HEAT @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the target temperature of a Zone.""" - return self._evo_device.setpointStatus["targetHeatTemperature"] + return self._evo_device.target_heat_temperature @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) - return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) + return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property def min_temp(self) -> float: @@ -214,7 +218,7 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 5, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["minHeatSetpoint"] + return self._evo_device.min_heat_setpoint @property def max_temp(self) -> float: @@ -222,17 +226,17 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 35, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["maxHeatSetpoint"] + return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: + elif self._evo_device.mode == EVO_TEMPOVER: until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) until = dt_util.as_utc(until) if until else None @@ -272,7 +276,7 @@ class EvoZone(EvoChild, EvoClimateEntity): await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return - temperature = self._evo_device.setpointStatus["targetHeatTemperature"] + temperature = self._evo_device.target_heat_temperature if evo_preset_mode == EVO_TEMPOVER: await self._update_schedule() @@ -311,6 +315,7 @@ class EvoController(EvoClimateEntity): super().__init__(evo_broker, evo_device) self._attr_unique_id = evo_device.systemId + self._evo_id = evo_device.systemId self._attr_name = evo_device.location.name modes = [m["systemMode"] for m in evo_broker.config["allowedSystemModes"]] @@ -352,7 +357,7 @@ class EvoController(EvoClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - tcs_mode = self._evo_tcs.systemModeStatus["mode"] + tcs_mode = self._evo_tcs.system_mode return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT @property @@ -362,16 +367,18 @@ class EvoController(EvoClimateEntity): Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperatureStatus["temperature"] + z.temperature for z in self._evo_tcs.zones.values() - if z.temperatureStatus["isAvailable"] + if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + if not self._evo_tcs.system_mode: + return None + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 58efb2c25b2..062bba1cfdc 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.6"] + "requirements": ["evohome-async==0.4.15"] } diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 5d49e9b46ec..51617bdf1cf 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -68,6 +68,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): super().__init__(evo_broker, evo_device) self._attr_unique_id = evo_device.dhwId + self._evo_id = evo_device.dhwId self._attr_precision = ( PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE @@ -79,15 +80,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity): @property def current_operation(self) -> str: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: return STATE_AUTO - return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + return EVO_STATE_TO_HA[self._evo_device.state] @property def is_away_mode_on(self): """Return True if away mode is on.""" - is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF - is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF + is_permanent = self._evo_device.mode == EVO_PERMOVER return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 5cbb206f223..c72fedaf59a 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,44 +1,88 @@ """Platform for FAA Delays sensor component.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any +from faadelays import Airport + from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FAADataUpdateCoordinator from .const import DOMAIN -FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( + +@dataclass(kw_only=True) +class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): + """Mixin for required keys.""" + + is_on_fn: Callable[[Airport], bool | None] + extra_state_attributes_fn: Callable[[Airport], Mapping[str, Any]] + + +FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( + FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", - name="Ground Delay", + translation_key="ground_delay", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_delay.status, + extra_state_attributes_fn=lambda airport: { + "average": airport.ground_delay.average, + "reason": airport.ground_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", - name="Ground Stop", + translation_key="ground_stop", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_stop.status, + extra_state_attributes_fn=lambda airport: { + "endtime": airport.ground_stop.endtime, + "reason": airport.ground_stop.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", - name="Departure Delay", + translation_key="depart_delay", icon="mdi:airplane-takeoff", + is_on_fn=lambda airport: airport.depart_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.depart_delay.minimum, + "maximum": airport.depart_delay.maximum, + "trend": airport.depart_delay.trend, + "reason": airport.depart_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", - name="Arrival Delay", + translation_key="arrive_delay", icon="mdi:airplane-landing", + is_on_fn=lambda airport: airport.arrive_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.arrive_delay.minimum, + "maximum": airport.arrive_delay.maximum, + "trend": airport.arrive_delay.trend, + "reason": airport.arrive_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="CLOSURE", - name="Closure", + translation_key="closure", icon="mdi:airplane:off", + is_on_fn=lambda airport: airport.closure.status, + extra_state_attributes_fn=lambda airport: { + "begin": airport.closure.start, + "end": airport.closure.end, + }, ), ) @@ -57,60 +101,38 @@ async def async_setup_entry( async_add_entities(entities) -class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): +class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity): """Define a binary sensor for FAA Delays.""" + _attr_has_entity_name = True + + entity_description: FaaDelaysBinarySensorEntityDescription + def __init__( - self, coordinator, entry_id, description: BinarySensorEntityDescription + self, + coordinator: FAADataUpdateCoordinator, + entry_id: str, + description: FaaDelaysBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - - self.coordinator = coordinator - self._entry_id = entry_id - self._attrs: dict[str, Any] = {} _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, _id)}, + name=_id, + manufacturer="Federal Aviation Administration", + entry_type=DeviceEntryType.SERVICE, + ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return the status of the sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - return self.coordinator.data.ground_delay.status - if sensor_type == "GROUND_STOP": - return self.coordinator.data.ground_stop.status - if sensor_type == "DEPART_DELAY": - return self.coordinator.data.depart_delay.status - if sensor_type == "ARRIVE_DELAY": - return self.coordinator.data.arrive_delay.status - if sensor_type == "CLOSURE": - return self.coordinator.data.closure.status - return None + return self.entity_description.is_on_fn(self.coordinator.data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return attributes for sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - self._attrs["average"] = self.coordinator.data.ground_delay.average - self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif sensor_type == "GROUND_STOP": - self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime - self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif sensor_type == "DEPART_DELAY": - self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum - self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum - self._attrs["trend"] = self.coordinator.data.depart_delay.trend - self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif sensor_type == "ARRIVE_DELAY": - self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum - self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum - self._attrs["trend"] = self.coordinator.data.arrive_delay.trend - self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif sensor_type == "CLOSURE": - self._attrs["begin"] = self.coordinator.data.closure.start - self._attrs["end"] = self.coordinator.data.closure.end - return self._attrs + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index b2f7f69dd49..2f91ce9f797 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -1,5 +1,6 @@ """Config flow for FAA Delays integration.""" import logging +from typing import Any from aiohttp import ClientConnectionError import faadelays @@ -7,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ID +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -21,7 +23,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py index f2aefdada66..2f110cf7730 100644 --- a/homeassistant/components/faa_delays/coordinator.py +++ b/homeassistant/components/faa_delays/coordinator.py @@ -6,6 +6,7 @@ import logging from aiohttp import ClientConnectionError from faadelays import Airport +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,19 +15,18 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class FAADataUpdateCoordinator(DataUpdateCoordinator): +class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]): """Class to manage fetching FAA API data from a single endpoint.""" - def __init__(self, hass, code): + def __init__(self, hass: HomeAssistant, code: str) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) ) self.session = aiohttp_client.async_get_clientsession(hass) self.data = Airport(code, self.session) - self.code = code - async def _async_update_data(self): + async def _async_update_data(self) -> Airport: try: async with asyncio.timeout(10): await self.data.update() diff --git a/homeassistant/components/faa_delays/strings.json b/homeassistant/components/faa_delays/strings.json index 92a9dafb4da..145c9e3ab34 100644 --- a/homeassistant/components/faa_delays/strings.json +++ b/homeassistant/components/faa_delays/strings.json @@ -17,5 +17,76 @@ "abort": { "already_configured": "This airport is already configured." } + }, + "entity": { + "binary_sensor": { + "ground_delay": { + "name": "Ground delay", + "state_attributes": { + "average": { + "name": "Average" + }, + "reason": { + "name": "Reason" + } + } + }, + "ground_stop": { + "name": "Ground stop", + "state_attributes": { + "endtime": { + "name": "End time" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "depart_delay": { + "name": "Departure delay", + "state_attributes": { + "minimum": { + "name": "Minimum" + }, + "maximum": { + "name": "Maximum" + }, + "trend": { + "name": "Trend" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "arrive_delay": { + "name": "Arrival delay", + "state_attributes": { + "minimum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::minimum::name%]" + }, + "maximum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::maximum::name%]" + }, + "trend": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::trend::name%]" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "closure": { + "name": "Closure", + "state_attributes": { + "begin": { + "name": "Begin" + }, + "end": { + "name": "End" + } + } + } + } } } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index a149909e029..21ffca35962 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -18,7 +18,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -77,8 +78,19 @@ ATTR_PRESET_MODES = "preset_modes" # mypy: disallow-any-generics -class NotValidPresetModeError(ValueError): - """Exception class when the preset_mode in not in the preset_modes list.""" +class NotValidPresetModeError(ServiceValidationError): + """Raised when the preset_mode is not in the preset_modes list.""" + + def __init__( + self, *args: object, translation_placeholders: dict[str, str] | None = None + ) -> None: + """Initialize the exception.""" + super().__init__( + *args, + translation_domain=DOMAIN, + translation_key="not_valid_preset_mode", + translation_placeholders=translation_placeholders, + ) @bind_hass @@ -107,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), vol.Optional(ATTR_PRESET_MODE): cv.string, }, - "async_turn_on", + "async_handle_turn_on_service", ) component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") @@ -156,7 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_PRESET_MODE, {vol.Required(ATTR_PRESET_MODE): cv.string}, - "async_set_preset_mode", + "async_handle_set_preset_mode_service", [FanEntityFeature.SET_SPEED, FanEntityFeature.PRESET_MODE], ) @@ -237,17 +249,30 @@ class FanEntity(ToggleEntity): """Set new preset mode.""" raise NotImplementedError() + @final + async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_preset_mode_or_raise(preset_mode) + await self.async_set_preset_mode(preset_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) + @final + @callback def _valid_preset_mode_or_raise(self, preset_mode: str) -> None: """Raise NotValidPresetModeError on invalid preset_mode.""" preset_modes = self.preset_modes if not preset_modes or preset_mode not in preset_modes: + preset_modes_str: str = ", ".join(preset_modes or []) raise NotValidPresetModeError( f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {preset_modes}" + f" {preset_modes}", + translation_placeholders={ + "preset_mode": preset_mode, + "preset_modes": preset_modes_str, + }, ) def set_direction(self, direction: str) -> None: @@ -267,6 +292,18 @@ class FanEntity(ToggleEntity): """Turn on the fan.""" raise NotImplementedError() + @final + async def async_handle_turn_on_service( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Validate and turn on the fan.""" + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + await self.async_turn_on(percentage, preset_mode, **kwargs) + async def async_turn_on( self, percentage: int | None = None, diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 674dcc2b92e..aab714d3e07 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -144,5 +144,10 @@ "reverse": "Reverse" } } + }, + "exceptions": { + "not_valid_preset_mode": { + "message": "Preset mode {preset_mode} is not valid, valid preset modes are: {preset_modes}." + } } } diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 33ad4853404..939ab4a40e5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,7 +24,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(hass.data[DOMAIN])]) + async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -38,9 +38,10 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_icon = "mdi:speedometer" _attr_should_poll = False - def __init__(self, speedtest_data: dict[str, Any]) -> None: + def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" self._speedtest_data = speedtest_data + self._attr_unique_id = entry_id async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index c309676c8d6..233388d5013 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -65,12 +65,12 @@ class FirmataBoard: except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) return False - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", self.name, err ) return False - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", self.name, diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index 8aa4cfb836c..f5b7cb5af40 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -41,12 +41,12 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", name, err ) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", name, err ) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caf0384eca2..caa47351f45 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -60,7 +60,10 @@ class FitbitOAuth2Implementation(AuthImplementation): resp.raise_for_status() except aiohttp.ClientResponseError as err: if _LOGGER.isEnabledFor(logging.DEBUG): - error_body = await resp.text() if not session.closed else "" + try: + error_body = await resp.text() + except aiohttp.ClientError: + error_body = "" _LOGGER.debug( "Client response error status=%s, body=%s", err.status, error_body ) diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json index 2ffb401f8c0..abdef61fb28 100644 --- a/homeassistant/components/fivem/strings.json +++ b/homeassistant/components/fivem/strings.json @@ -6,6 +6,9 @@ "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your FiveM server." } } }, diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 142694a6bfb..ee989bb2ee0 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -131,11 +131,9 @@ class Fan(CoordinatorEntity[FjaraskupanCoordinator], FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if command := PRESET_TO_COMMAND.get(preset_mode): - async with self.coordinator.async_connect_and_update() as device: - await device.send_command(command) - else: - raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") + command = PRESET_TO_COMMAND[preset_mode] + async with self.coordinator.async_connect_and_update() as device: + await device.send_command(command) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..c9a0b332d93 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -0,0 +1,43 @@ +"""The Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flexit Nordic (BACnet) from a config entry.""" + + device = FlexitBACnet(entry.data[CONF_IP_ADDRESS], entry.data[CONF_DEVICE_ID]) + + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {entry.data['address']}" + ) from exc + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py new file mode 100644 index 00000000000..28f4a6ae178 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -0,0 +1,148 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from typing import Any + +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, + FlexitBACnet, +) +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + PRESET_TO_VENTILATION_MODE_MAP, + VENTILATION_TO_PRESET_MODE_MAP, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the Flexit Nordic unit.""" + device = hass.data[DOMAIN][config_entry.entry_id] + + async_add_devices([FlexitClimateEntity(device)]) + + +class FlexitClimateEntity(ClimateEntity): + """Flexit air handling unit.""" + + _attr_name = None + + _attr_has_entity_name = True + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + ] + + _attr_preset_modes = [ + PRESET_AWAY, + PRESET_HOME, + PRESET_BOOST, + ] + + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: FlexitBACnet) -> None: + """Initialize the unit.""" + self._device = device + self._attr_unique_id = device.serial_number + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, device.serial_number), + }, + name=device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=device.serial_number, + ) + + async def async_update(self) -> None: + """Refresh unit state.""" + await self._device.update() + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + return self._device.air_temp_setpoint_away + + return self._device.air_temp_setpoint_home + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + try: + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + await self._device.set_air_temp_setpoint_away(temperature) + else: + await self._device.set_air_temp_setpoint_home(temperature) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_mode] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] + + try: + await self._device.set_ventilation_mode(ventilation_mode) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._device.ventilation_mode == VENTILATION_MODE_STOP: + return HVACMode.OFF + + return HVACMode.FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + try: + if hvac_mode == HVACMode.OFF: + await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + else: + await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py new file mode 100644 index 00000000000..2c87dfc5b97 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions +import logging +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_ID = 2 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flexit Nordic (BACnet).""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + device = FlexitBACnet( + user_input[CONF_IP_ADDRESS], user_input[CONF_DEVICE_ID] + ) + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device.device_name, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py new file mode 100644 index 00000000000..269a88c4cec --- /dev/null +++ b/homeassistant/components/flexit_bacnet/const.py @@ -0,0 +1,30 @@ +"""Constants for the Flexit Nordic (BACnet) integration.""" +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HIGH, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + PRESET_NONE, +) + +DOMAIN = "flexit_bacnet" + +VENTILATION_TO_PRESET_MODE_MAP = { + VENTILATION_MODE_STOP: PRESET_NONE, + VENTILATION_MODE_AWAY: PRESET_AWAY, + VENTILATION_MODE_HOME: PRESET_HOME, + VENTILATION_MODE_HIGH: PRESET_BOOST, +} + +PRESET_TO_VENTILATION_MODE_MAP = { + PRESET_NONE: VENTILATION_MODE_STOP, + PRESET_AWAY: VENTILATION_MODE_AWAY, + PRESET_HOME: VENTILATION_MODE_HOME, + PRESET_BOOST: VENTILATION_MODE_HIGH, +} diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json new file mode 100644 index 00000000000..d230e4ebb7a --- /dev/null +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "flexit_bacnet", + "name": "Flexit Nordic (BACnet)", + "codeowners": ["@lellky", "@piotrbulinski"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["flexit_bacnet==2.1.0"] +} diff --git a/homeassistant/components/komfovent/strings.json b/homeassistant/components/flexit_bacnet/strings.json similarity index 52% rename from homeassistant/components/komfovent/strings.json rename to homeassistant/components/flexit_bacnet/strings.json index 074754c1fe0..fd2725c6403 100644 --- a/homeassistant/components/komfovent/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -3,16 +3,13 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "ip_address": "[%key:common::config_flow::data::ip%]", + "device_id": "[%key:common::config_flow::data::device%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_input": "Failed to parse provided hostname", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 627f562be7e..3444911fbd4 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Flo device." } } }, diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 35964ee4546..de22006b274 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -9,6 +9,9 @@ "password": "[%key:common::config_flow::data::password%]", "rtsp_port": "RTSP port", "stream": "Stream" + }, + "data_description": { + "host": "The hostname or IP address of your Foscam camera." } } }, diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 5c4143b4562..eaa56a38da1 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Freebox router." } }, "link": { diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 7cbb10a236b..5eed2f59fc4 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -26,6 +26,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } } }, diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index d31ccd180c4..8dc51e59738 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -38,11 +38,9 @@ async def async_setup_entry( FritzboxLight( coordinator, ain, - device.get_colors(), - device.get_color_temps(), ) for ain in coordinator.new_devices - if (device := coordinator.data.devices[ain]).has_lightbulb + if (coordinator.data.devices[ain]).has_lightbulb ) entry.async_on_unload(coordinator.async_add_listener(_add_entities)) @@ -57,27 +55,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): self, coordinator: FritzboxDataUpdateCoordinator, ain: str, - supported_colors: dict, - supported_color_temps: list[int], ) -> None: """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - - if supported_color_temps: - # only available for color bulbs - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) - - # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. - # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup self._supported_hs: dict[int, list[int]] = {} - for values in supported_colors.values(): - hue = int(values[0][0]) - self._supported_hs[hue] = [ - int(values[0][1]), - int(values[1][1]), - int(values[2][1]), - ] @property def is_on(self) -> bool: @@ -173,3 +154,28 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Turn the light off.""" await self.hass.async_add_executor_job(self.data.set_state_off) await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """Get light attributes from device after entity is added to hass.""" + await super().async_added_to_hass() + supported_colors = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_colors + ) + supported_color_temps = await self.hass.async_add_executor_job( + self.coordinator.data.devices[self.ain].get_color_temps + ) + + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index d5607aa3090..f4d2fe3670e 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "confirm": { diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 89f049bfbe9..ac36942eec2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -8,6 +8,9 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your FRITZ!Box router." } }, "phonebook": { diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 469deab23e1..2a7ef1396d5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231030.2"] + "requirements": ["home-assistant-frontend==20231208.2"] } diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 82f169dc6c9..91646dcb745 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,7 +1,7 @@ """API for persistent storage for the frontend.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps from typing import Any @@ -50,12 +50,19 @@ async def async_user_store( return store, data[user_id] -def with_store(orig_func: Callable) -> Callable: +def with_store( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to provide data.""" @wraps(orig_func) async def with_store_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" user_id = connection.user.id diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index a10c3f535a1..03d9f28c016 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -5,10 +5,13 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Frontier Silicon device." } }, "device_config": { - "title": "Device Configuration", + "title": "Device configuration", "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", "data": { "pin": "[%key:common::config_flow::data::pin%]" diff --git a/homeassistant/components/fujitsu_anywair/__init__.py b/homeassistant/components/fujitsu_anywair/__init__.py new file mode 100644 index 00000000000..5845e00f8b0 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/__init__.py @@ -0,0 +1 @@ +"""Fujitsu anywAIR virtual integration for Home Assistant.""" diff --git a/homeassistant/components/fujitsu_anywair/manifest.json b/homeassistant/components/fujitsu_anywair/manifest.json new file mode 100644 index 00000000000..463f0724919 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "fujitsu_anywair", + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" +} diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 17facb79dbb..203251351ae 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -19,13 +19,14 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" + self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( async_get_clientsession(hass), entry.data[CONF_HOST], DEFAULT_PORT, entry.data[CONF_PASSWORD], - use_ssl=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], + use_ssl=self.use_ssl, + verify_ssl=entry.data.get(CONF_VERIFY_SSL, False), ) super().__init__( hass, @@ -33,7 +34,6 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) - self.use_ssl = entry.data[CONF_SSL] async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index bf46feeec3f..c1a1ef1fcf0 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -13,6 +13,9 @@ "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the device running your Fully Kiosk Browser application." } } }, diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 5d5589c54d6..8a0a0113ced 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -7,6 +7,7 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -44,12 +45,14 @@ class GdacsSensor(SensorEntity): _attr_should_poll = False _attr_icon = DEFAULT_ICON _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, config_entry: ConfigEntry, manager) -> None: """Initialize entity.""" + assert config_entry.unique_id self._config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry.unique_id - self._attr_name = f"GDACS ({config_entry.title})" self._manager = manager self._status = None self._last_update = None @@ -60,6 +63,11 @@ class GdacsSensor(SensorEntity): self._updated = None self._removed = None self._remove_signal_status: Callable[[], None] | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="GDACS", + ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 79ba418d509..7b9bf8f6112 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -68,9 +68,12 @@ class GeniusSwitch(GeniusZone, SwitchEntity): def is_on(self) -> bool: """Return the current state of the on/off zone. - The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + The zone is considered 'on' if the mode is either 'override' or 'timer'. """ - return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + return ( + self._zone.data["mode"] in ["override", "timer"] + and self._zone.data["setpoint"] + ) async def async_turn_off(self, **kwargs: Any) -> None: """Send the zone to Timer mode. diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index fdd0c44b31b..1bab098d65f 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -10,6 +10,9 @@ "version": "Glances API Version (2 or 3)", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system running your Glances system monitor." } }, "reauth_confirm": { diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index d94f5219607..c6d85bd4c10 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your Goal Zero Yeti." } }, "confirm_discovery": { diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index af892f15af4..c89925664e0 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -686,8 +686,12 @@ class GoogleEntity: return device # Add Matter info - if "matter" in self.hass.config.components and ( - matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + if ( + "matter" in self.hass.config.components + and any(x for x in device_entry.identifiers if x[0] == "matter") + and ( + matter_info := matter.get_matter_device_info(self.hass, device_entry.id) + ) ): device["matterUniqueId"] = matter_info["unique_id"] device["matterOriginalVendorId"] = matter_info["vendor_id"] diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index d3c4dfa6936..130c0d2cc01 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -1,7 +1,7 @@ """Google Tasks todo platform.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, datetime, timedelta from typing import Any, cast from homeassistant.components.todo import ( @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .api import AsyncConfigEntryAuth from .const import DOMAIN @@ -35,9 +36,31 @@ def _convert_todo_item(item: TodoItem) -> dict[str, str]: result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + if (due := item.due) is not None: + # due API field is a timestamp string, but with only date resolution + result["due"] = dt_util.start_of_local_day(due).isoformat() + if (description := item.description) is not None: + result["notes"] = description return result +def _convert_api_item(item: dict[str, str]) -> TodoItem: + """Convert tasks API items into a TodoItem.""" + due: date | None = None + if (due_str := item.get("due")) is not None: + due = datetime.fromisoformat(due_str).date() + return TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status", ""), + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.get("notes"), + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -68,6 +91,8 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -88,17 +113,7 @@ class GoogleTaskTodoListEntity( """Get the current set of To-do items.""" if self.coordinator.data is None: return None - return [ - TodoItem( - summary=item["title"], - uid=item["id"], - status=TODO_STATUS_MAP.get( - item.get("status"), # type: ignore[arg-type] - TodoItemStatus.NEEDS_ACTION, - ), - ) - for item in _order_tasks(self.coordinator.data) - ] + return [_convert_api_item(item) for item in _order_tasks(self.coordinator.data)] async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index ff3438ed53f..13e93d780b2 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -11,7 +11,6 @@ from homeassistant.helpers.event import async_track_time_interval from .bridge import DiscoveryService from .const import ( COORDINATORS, - DATA_DISCOVERY_INTERVAL, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DISPATCHERS, @@ -29,7 +28,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gree_discovery = DiscoveryService(hass) hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery - hass.data[DOMAIN].setdefault(DISPATCHERS, []) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def _async_scan_update(_=None): @@ -39,8 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Scanning network for Gree devices") await _async_scan_update() - hass.data[DOMAIN][DATA_DISCOVERY_INTERVAL] = async_track_time_interval( - hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) ) return True @@ -48,13 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN].get(DISPATCHERS) is not None: - for cleanup in hass.data[DOMAIN][DISPATCHERS]: - cleanup() - - if hass.data[DOMAIN].get(DATA_DISCOVERY_INTERVAL) is not None: - hass.data[DOMAIN].pop(DATA_DISCOVERY_INTERVAL)() - if hass.data.get(DATA_DISCOVERY_SERVICE) is not None: hass.data.pop(DATA_DISCOVERY_SERVICE) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index b14b9cfaba4..8d50cdf2aed 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -38,21 +38,19 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .bridge import DeviceDataUpdateCoordinator from .const import ( COORDINATORS, DISPATCH_DEVICE_DISCOVERED, - DISPATCHERS, DOMAIN, FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) +from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -88,7 +86,7 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -101,12 +99,12 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) -class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateEntity): +class GreeClimateEntity(GreeEntity, ClimateEntity): """Representation of a Gree HVAC device.""" _attr_precision = PRECISION_WHOLE @@ -121,19 +119,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _attr_preset_modes = PRESET_MODES _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES + _attr_name = None def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" super().__init__(coordinator) - self._attr_name = coordinator.device.device_info.name - mac = coordinator.device.device_info.mac - self._attr_unique_id = mac - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, mac)}, - identifiers={(DOMAIN, mac)}, - manufacturer="Gree", - name=self._attr_name, - ) + self._attr_unique_id = coordinator.device.device_info.mac units = self.coordinator.device.temperature_units if units == TemperatureUnits.C: self._attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py index b4df7a1acde..46479210921 100644 --- a/homeassistant/components/gree/const.py +++ b/homeassistant/components/gree/const.py @@ -3,7 +3,6 @@ COORDINATORS = "coordinators" DATA_DISCOVERY_SERVICE = "gree_discovery" -DATA_DISCOVERY_INTERVAL = "gree_discovery_interval" DISCOVERY_SCAN_INTERVAL = 300 DISCOVERY_TIMEOUT = 8 diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index fd1b80ef90d..c965ad45721 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -9,13 +9,15 @@ from .const import DOMAIN class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Generic Gree entity (base class).""" - def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + _attr_has_entity_name = True + + def __init__( + self, coordinator: DeviceDataUpdateCoordinator, desc: str | None = None + ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._desc = desc name = coordinator.device.device_info.name mac = coordinator.device.device_info.mac - self._attr_name = f"{name} {desc}" self._attr_unique_id = f"{mac}_{desc}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, mac)}, diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index ad8f0f41ae7..45911433b92 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -9,5 +9,24 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "switch": { + "light": { + "name": "Panel light" + }, + "quiet": { + "name": "Quiet" + }, + "fresh_air": { + "name": "Fresh air" + }, + "xfan": { + "name": "XFan" + }, + "health_mode": { + "name": "Health mode" + } + } } } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 68c11ad6e1f..3c1893f7735 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity @@ -33,10 +33,6 @@ class GreeRequiredKeysMixin: class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): """Describes Gree switch entity.""" - # GreeSwitch does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" @@ -66,33 +62,33 @@ def _set_anion(device: Device, value: bool) -> None: GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GreeSwitchEntityDescription( icon="mdi:lightbulb", - name="Panel Light", - key="light", + key="Panel Light", + translation_key="light", get_value_fn=lambda d: d.light, set_value_fn=_set_light, ), GreeSwitchEntityDescription( - name="Quiet", - key="quiet", + key="Quiet", + translation_key="quiet", get_value_fn=lambda d: d.quiet, set_value_fn=_set_quiet, ), GreeSwitchEntityDescription( - name="Fresh Air", - key="fresh_air", + key="Fresh Air", + translation_key="fresh_air", get_value_fn=lambda d: d.fresh_air, set_value_fn=_set_fresh_air, ), GreeSwitchEntityDescription( - name="XFan", - key="xfan", + key="XFan", + translation_key="xfan", get_value_fn=lambda d: d.xfan, set_value_fn=_set_xfan, ), GreeSwitchEntityDescription( icon="mdi:pine-tree", - name="Health mode", - key="anion", + key="Health mode", + translation_key="health_mode", get_value_fn=lambda d: d.anion, set_value_fn=_set_anion, entity_registry_enabled_default=False, @@ -102,7 +98,7 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" @@ -119,7 +115,7 @@ async def async_setup_entry( for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) - hass.data[DOMAIN][DISPATCHERS].append( + entry.async_on_unload( async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) ) @@ -134,7 +130,7 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, description.name) + super().__init__(coordinator, description.key) @property def is_on(self) -> bool: diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index d7a9fe4e836..bd2cb8c96de 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import cast +from typing import Any, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -170,7 +170,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def call_with_data(func: Callable) -> Callable: + def call_with_data( + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Hydrate a service call with the appropriate GuardianData object.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index c9c7a559758..f6862ca3c83 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "Hub Name" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Harmony Hub." } }, "link": { diff --git a/homeassistant/components/harmony/switch.py b/homeassistant/components/harmony/switch.py index a3c588c06bb..2d072f11f2c 100644 --- a/homeassistant/components/harmony/switch.py +++ b/homeassistant/components/harmony/switch.py @@ -23,15 +23,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up harmony activity switches.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_switches", - breaks_in_ha_version="2023.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_switches", - ) data = hass.data[DOMAIN][entry.entry_id][HARMONY_DATA] activities = data.activities @@ -65,10 +56,28 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Start this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_start_activity(self._activity_name) async def async_turn_off(self, **kwargs: Any) -> None: """Stop this activity.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_switches", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_switches", + ) await self._data.async_power_off() async def async_added_to_hass(self) -> None: @@ -91,7 +100,7 @@ class HarmonyActivitySwitch(HarmonyEntity, SwitchEntity): self.hass, DOMAIN, f"deprecated_switches_{self.entity_id}_{item}", - breaks_in_ha_version="2023.8.0", + breaks_in_ha_version="2024.6.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_switches_entity", diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 419d80484cf..9d72d5842fd 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging import os import re +from typing import TYPE_CHECKING from urllib.parse import quote, unquote import aiohttp @@ -156,6 +157,9 @@ class HassIOView(HomeAssistantView): # _stored_content_type is only computed once `content_type` is accessed if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary + if TYPE_CHECKING: + # pylint: disable-next=protected-access + assert isinstance(request._stored_content_type, str) # pylint: disable-next=protected-access headers[CONTENT_TYPE] = request._stored_content_type diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index b29f80ff2b3..0c0fe55b686 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -17,7 +17,6 @@ from yarl import URL from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.typing import UNDEFINED from .const import X_HASS_SOURCE, X_INGRESS_PATH @@ -68,18 +67,20 @@ class HassIOIngress(HomeAssistantView): self._websession = websession @lru_cache - def _create_url(self, token: str, path: str) -> str: + def _create_url(self, token: str, path: str) -> URL: """Create URL to service.""" base_path = f"/ingress/{token}/" url = f"http://{self._host}{base_path}{quote(path)}" try: - if not URL(url).path.startswith(base_path): - raise HTTPBadRequest() + target_url = URL(url) except ValueError as err: raise HTTPBadRequest() from err - return url + if not target_url.path.startswith(base_path): + raise HTTPBadRequest() + + return target_url async def _handle( self, request: web.Request, token: str, path: str @@ -129,7 +130,7 @@ class HassIOIngress(HomeAssistantView): # Support GET query if request.query_string: - url = f"{url}?{request.query_string}" + url = url.with_query(request.query_string) # Start proxy async with self._websession.ws_connect( @@ -172,7 +173,7 @@ class HassIOIngress(HomeAssistantView): content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) # Avoid parsing content_type in simple cases for better performance if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): - content_type = (maybe_content_type.partition(";"))[0].strip() + content_type: str = (maybe_content_type.partition(";"))[0].strip() else: content_type = result.content_type # Simple request @@ -188,11 +189,12 @@ class HassIOIngress(HomeAssistantView): status=result.status, content_type=content_type, body=body, + zlib_executor_size=32768, ) if content_length_int > MIN_COMPRESSED_SIZE and should_compress( content_type or simple_response.content_type ): - enable_compression(simple_response) + simple_response.enable_compression() await simple_response.prepare(request) return simple_response diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 8337405641c..fcfe23dda6e 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -1,6 +1,7 @@ """Repairs implementation for supervisor integration.""" +from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from types import MethodType from typing import Any @@ -116,7 +117,12 @@ class SupervisorIssueRepairFlow(RepairsFlow): return self.async_create_entry(data={}) @staticmethod - def _async_step(suggestion: Suggestion) -> Callable: + def _async_step( + suggestion: Suggestion, + ) -> Callable[ + [SupervisorIssueRepairFlow, dict[str, str] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler for a suggestion.""" async def _async_step( diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 7bd362cf3d7..df18fc7834a 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -6,6 +6,9 @@ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your HEOS device." } } }, diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index f5b97a7fb13..9eab92dce5c 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -21,7 +21,7 @@ import homeassistant.util.dt as dt_util from . import websocket_api from .const import DOMAIN -from .helpers import entities_may_have_state_changes_after +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after CONF_ORDER = "use_include_order" @@ -106,7 +106,8 @@ class HistoryPeriodView(HomeAssistantView): no_attributes = "no_attributes" in request.query if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes diff --git a/homeassistant/components/history/helpers.py b/homeassistant/components/history/helpers.py index 523b1fafb7f..7e28e69e5f9 100644 --- a/homeassistant/components/history/helpers.py +++ b/homeassistant/components/history/helpers.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import process_timestamp from homeassistant.core import HomeAssistant @@ -21,3 +23,10 @@ def entities_may_have_state_changes_after( return True return False + + +def has_recorder_run_after(hass: HomeAssistant, run_time: dt) -> bool: + """Check if the recorder has any runs after a specific time.""" + return run_time >= process_timestamp( + get_instance(hass).recorder_runs_manager.first.start + ) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 24ec07b6a87..4be63f29c02 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -39,7 +39,7 @@ from homeassistant.helpers.typing import EventType import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES -from .helpers import entities_may_have_state_changes_after +from .helpers import entities_may_have_state_changes_after, has_recorder_run_after _LOGGER = logging.getLogger(__name__) @@ -142,7 +142,8 @@ async def ws_get_history_during_period( no_attributes = msg["no_attributes"] if ( - not include_start_time_state + (end_time and not has_recorder_run_after(hass, end_time)) + or not include_start_time_state and entity_ids and not entities_may_have_state_changes_after( hass, entity_ids, start_time, no_attributes diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json index d6e3212b4ea..ba74547e355 100644 --- a/homeassistant/components/hlk_sw16/strings.json +++ b/homeassistant/components/hlk_sw16/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hi-Link HLK-SW-16 device." } } }, diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py new file mode 100644 index 00000000000..224b1b01294 --- /dev/null +++ b/homeassistant/components/holiday/__init__.py @@ -0,0 +1,20 @@ +"""The Holiday integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Holiday from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py new file mode 100644 index 00000000000..bb9a332cb73 --- /dev/null +++ b/homeassistant/components/holiday/calendar.py @@ -0,0 +1,134 @@ +"""Holiday Calendar.""" +from __future__ import annotations + +from datetime import datetime + +from holidays import HolidayBase, country_holidays + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import CONF_PROVINCE, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + country: str = config_entry.data[CONF_COUNTRY] + province: str | None = config_entry.data.get(CONF_PROVINCE) + language = hass.config.language + + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=language, + ) + if language == "en": + for lang in obj_holidays.supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=lang, + ) + language = lang + break + + async_add_entities( + [ + HolidayCalendarEntity( + config_entry.title, + country, + province, + language, + obj_holidays, + config_entry.entry_id, + ) + ], + True, + ) + + +class HolidayCalendarEntity(CalendarEntity): + """Representation of a Holiday Calendar element.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + name: str, + country: str, + province: str | None, + language: str, + obj_holidays: HolidayBase, + unique_id: str, + ) -> None: + """Initialize HolidayCalendarEntity.""" + self._country = country + self._province = province + self._location = name + self._language = language + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) + self._obj_holidays = obj_holidays + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + next_holiday = None + for holiday_date, holiday_name in sorted( + self._obj_holidays.items(), key=lambda x: x[0] + ): + if holiday_date >= dt_util.now().date(): + next_holiday = (holiday_date, holiday_name) + break + + if next_holiday is None: + return None + + return CalendarEvent( + summary=next_holiday[1], + start=next_holiday[0], + end=next_holiday[0], + location=self._location, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + obj_holidays = country_holidays( + self._country, + subdiv=self._province, + years=list({start_date.year, end_date.year}), + language=self._language, + ) + + event_list: list[CalendarEvent] = [] + + for holiday_date, holiday_name in obj_holidays.items(): + if start_date.date() <= holiday_date <= end_date.date(): + event = CalendarEvent( + summary=holiday_name, + start=holiday_date, + end=holiday_date, + location=self._location, + ) + event_list.append(event) + + return event_list diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py new file mode 100644 index 00000000000..1ba4a2a0c26 --- /dev/null +++ b/homeassistant/components/holiday/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for Holiday integration.""" +from __future__ import annotations + +from typing import Any + +from babel import Locale +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_PROVINCE, DOMAIN + +SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) + + +class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Holiday.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self.data = user_input + + selected_country = user_input[CONF_COUNTRY] + + if SUPPORTED_COUNTRIES[selected_country]: + return await self.async_step_province() + + self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) + + locale = Locale(self.hass.config.language) + title = locale.territories[selected_country] + return self.async_create_entry(title=title, data=user_input) + + user_schema = vol.Schema( + { + vol.Optional( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector( + CountrySelectorConfig( + countries=list(SUPPORTED_COUNTRIES), + ) + ), + } + ) + + return self.async_show_form(step_id="user", data_schema=user_schema) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the province step.""" + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + + country = combined_input[CONF_COUNTRY] + province = combined_input.get(CONF_PROVINCE) + + self._async_abort_entries_match( + { + CONF_COUNTRY: country, + CONF_PROVINCE: province, + } + ) + + locale = Locale(self.hass.config.language) + province_str = f", {province}" if province else "" + name = f"{locale.territories[country]}{province_str}" + + return self.async_create_entry(title=name, data=combined_input) + + province_schema = vol.Schema( + { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + return self.async_show_form(step_id="province", data_schema=province_schema) diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py new file mode 100644 index 00000000000..5d2a567a488 --- /dev/null +++ b/homeassistant/components/holiday/const.py @@ -0,0 +1,6 @@ +"""Constants for the Holiday integration.""" +from typing import Final + +DOMAIN: Final = "holiday" + +CONF_PROVINCE: Final = "province" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json new file mode 100644 index 00000000000..f73577bddee --- /dev/null +++ b/homeassistant/components/holiday/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "holiday", + "name": "Holiday", + "codeowners": ["@jrieger"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/holiday", + "iot_class": "local_polling", + "requirements": ["holidays==0.37", "babel==2.13.1"] +} diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json new file mode 100644 index 00000000000..4762a48c659 --- /dev/null +++ b/homeassistant/components/holiday/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Already configured. Only a single configuration for country/province combination possible." + }, + "step": { + "user": { + "data": { + "country": "Country" + } + }, + "province": { + "data": { + "province": "Province" + } + } + } + } +} diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 16a7ee5009c..926ab5025f6 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -38,6 +38,7 @@ DEFAULT_EXPOSED_DOMAINS = { "scene", "script", "switch", + "todo", "vacuum", "water_heater", } diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 3308083f22f..9abfefc996f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -30,11 +30,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - entity_platform, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform from homeassistant.helpers.service import ( async_extract_entity_ids, @@ -208,7 +204,7 @@ async def async_setup_platform( await platform.async_reset() # Extract only the config for the Home Assistant platform, ignore the rest. - for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN): if p_type != HA_DOMAIN: continue diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 51686e54c55..84aafb44808 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -1,8 +1,8 @@ """Offer Home Assistant core automation rules.""" import voluptuous as vol -from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import CONF_EVENT, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -30,24 +30,17 @@ async def async_attach_trigger( job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: - - @callback - def hass_shutdown(event): - """Execute when Home Assistant is shutting down.""" - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": "homeassistant", - "event": event, - "description": "Home Assistant stopping", - } - }, - event.context, - ) - - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown) + return hass.async_add_shutdown_job( + job, + { + "trigger": { + **trigger_data, + "platform": "homeassistant", + "event": event, + "description": "Home Assistant stopping", + } + }, + ) # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 036f6c077da..35b303a62e3 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,12 +1,61 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, PLATFORMS +from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import HWEnergyDeviceUpdateCoordinator as Coordinator +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate old entry. + + The HWE-SKT had no total_power_*_kwh in 2023.11, in 2023.12 it does. + But simultaneously, the total_power_*_t1_kwh was removed for HWE-SKT. + + This migration migrates the old unique_id to the new one, if possible. + + Migration can be removed after 2024.6 + """ + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + replacements = { + "total_power_import_t1_kwh": "total_power_import_kwh", + "total_power_export_t1_kwh": "total_power_export_kwh", + } + + for old_id, new_id in replacements.items(): + if entry.unique_id.endswith(old_id): + new_unique_id = entry.unique_id.replace(old_id, new_id) + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'", + new_unique_id, + existing_entity_id, + ) + return None + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" coordinator = Coordinator(hass) @@ -21,6 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise + await _async_migrate_entries(hass, entry) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator # Abort reauth config flow if active diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index ff065592283..d4692ee8bf0 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +import logging from homewizard_energy.models import Data, Device, State, System @@ -11,6 +12,8 @@ from homeassistant.const import Platform DOMAIN = "homewizard" PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +LOGGER = logging.getLogger(__package__) + # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 58e0b02a06c..ced870d7072 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -45,7 +46,9 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) + await self.coordinator.api.state_set( + brightness=value_to_brightness((0, 100), value) + ) await self.coordinator.async_refresh() @property @@ -61,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): or (brightness := self.coordinator.data.state.brightness) is None ): return None - return round(brightness * (100 / 255)) + return brightness_to_value((0, 100), brightness) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 78cee9ee6fe..d980e66e0e4 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -436,7 +436,6 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( HomeWizardSensorEntity(coordinator, description) for description in SENSORS diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5a1d182e80c..449f00fb335 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -16,7 +16,6 @@ from aiohttp.http_parser import RawRequestMessage from aiohttp.streams import StreamReader from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection -from aiohttp.web_log import AccessLogger from aiohttp.web_protocol import RequestHandler from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher from aiohttp_zlib_ng import enable_zlib_ng @@ -238,25 +237,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class HomeAssistantAccessLogger(AccessLogger): - """Access logger for Home Assistant that does not log when disabled.""" - - def log( - self, request: web.BaseRequest, response: web.StreamResponse, time: float - ) -> None: - """Log the request. - - The default implementation logs the request to the logger - with the INFO level and than throws it away if the logger - is not enabled for the INFO level. This implementation - does not log the request if the logger is not enabled for - the INFO level. - """ - if not self.logger.isEnabledFor(logging.INFO): - return - super().log(request, response, time) - - class HomeAssistantRequest(web.Request): """Home Assistant request object.""" @@ -540,9 +520,7 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner( - self.app, access_log_class=HomeAssistantAccessLogger - ) + self.runner = web.AppRunner(self.app, handler_cancellation=True) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 7481381bbc8..1be3d761a3b 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -20,7 +20,6 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant, is_callback -from homeassistant.helpers.aiohttp_compat import enable_compression from homeassistant.helpers.json import ( find_paths_unserializable_data, json_bytes, @@ -72,8 +71,9 @@ class HomeAssistantView: content_type=CONTENT_TYPE_JSON, status=int(status_code), headers=headers, + zlib_executor_size=32768, ) - enable_compression(response) + response.enable_compression() return response def json_message( diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index d8c939e5c3a..dcd40b8346c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -135,6 +135,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, + Platform.SELECT, ] diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py new file mode 100644 index 00000000000..2f4b7274fc0 --- /dev/null +++ b/homeassistant/components/huawei_lte/select.py @@ -0,0 +1,132 @@ +"""Support for Huawei LTE selects.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import partial +import logging + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from . import HuaweiLteBaseEntityWithDevice +from .const import DOMAIN, KEY_NET_NET_MODE + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class HuaweiSelectEntityMixin: + """Mixin for Huawei LTE select entities, to ensure required fields are set.""" + + setter_fn: Callable[[str], None] + + +@dataclass +class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin): + """Class describing Huawei LTE select entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + selects: list[Entity] = [] + + desc = HuaweiSelectEntityDescription( + key=KEY_NET_NET_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:transmission-tower", + name="Preferred network mode", + translation_key="preferred_network_mode", + options=[ + NetworkModeEnum.MODE_AUTO.value, + NetworkModeEnum.MODE_4G_3G_AUTO.value, + NetworkModeEnum.MODE_4G_2G_AUTO.value, + NetworkModeEnum.MODE_4G_ONLY.value, + NetworkModeEnum.MODE_3G_2G_AUTO.value, + NetworkModeEnum.MODE_3G_ONLY.value, + NetworkModeEnum.MODE_2G_ONLY.value, + ], + setter_fn=partial( + router.client.net.set_net_mode, + LTEBandEnum.ALL, + NetworkBandEnum.ALL, + ), + ) + selects.append( + HuaweiLteSelectEntity( + router, + entity_description=desc, + key=desc.key, + item="NetworkMode", + ) + ) + + async_add_entities(selects, True) + + +@dataclass +class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity): + """Huawei LTE select entity.""" + + entity_description: HuaweiSelectEntityDescription + key: str + item: str + + _raw_state: str | None = field(default=None, init=False) + + def __post_init__(self) -> None: + """Initialize remaining attributes.""" + name = None + if self.entity_description.name != UNDEFINED: + name = self.entity_description.name + self._attr_name = name or self.item + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self.entity_description.setter_fn(option) + + @property + def current_option(self) -> str | None: + """Return current option.""" + return self._raw_state + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + async def async_added_to_hass(self) -> None: + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].append(f"{SELECT_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SELECT_DOMAIN}/{self.item}") + + async def async_update(self) -> None: + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index ee7256340df..ca3734bb305 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -8,8 +8,6 @@ from datetime import datetime, timedelta import logging import re -from huawei_lte_api.enums.net import NetworkModeEnum - from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, @@ -575,10 +573,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { "State": HuaweiSensorEntityDescription( key="State", translation_key="operator_search_mode", - format_fn=lambda x: ( - {"0": "Auto", "1": "Manual"}.get(x), - None, - ), entity_category=EntityCategory.DIAGNOSTIC, ), }, @@ -588,19 +582,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "NetworkMode": HuaweiSensorEntityDescription( key="NetworkMode", - translation_key="preferred_mode", - format_fn=lambda x: ( - { - NetworkModeEnum.MODE_AUTO.value: "4G/3G/2G", - NetworkModeEnum.MODE_4G_3G_AUTO.value: "4G/3G", - NetworkModeEnum.MODE_4G_2G_AUTO.value: "4G/2G", - NetworkModeEnum.MODE_4G_ONLY.value: "4G", - NetworkModeEnum.MODE_3G_2G_AUTO.value: "3G/2G", - NetworkModeEnum.MODE_3G_ONLY.value: "3G", - NetworkModeEnum.MODE_2G_ONLY.value: "2G", - }.get(x), - None, - ), + translation_key="preferred_network_mode", entity_category=EntityCategory.DIAGNOSTIC, ), }, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 9e46ca742b8..225146799a3 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -231,10 +231,23 @@ "name": "Operator code" }, "operator_search_mode": { - "name": "Operator search mode" + "name": "Operator search mode", + "state": { + "0": "Auto", + "1": "Manual" + } }, - "preferred_mode": { - "name": "Preferred mode" + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } }, "sms_deleted_device": { "name": "SMS deleted (device)" @@ -273,6 +286,20 @@ "name": "SMS messages (SIM)" } }, + "select": { + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } + } + }, "switch": { "mobile_data": { "name": "Mobile data" diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 4022c61bc36..114f501d7a3 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -5,12 +5,18 @@ "title": "Pick Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hue bridge." } }, "manual": { "title": "Manual configure a Hue bridge", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::hue::config::step::init::data_description::host%]" } }, "link": { diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index a8efb663c90..b30a9b375b0 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -30,6 +30,8 @@ ATTR_DIRECTION = "direction" ATTR_TYPE = "type" ATTR_DELAY = "delay" ATTR_NEXT = "next" +ATTR_CANCELLED = "cancelled" +ATTR_EXTRA = "extra" PARALLEL_UPDATES = 0 BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin") @@ -142,6 +144,8 @@ class HVVDepartureSensor(SensorEntity): departure = data["departures"][0] line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) self._attr_available = True self._attr_native_value = ( departure_time @@ -157,6 +161,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) @@ -164,6 +170,8 @@ class HVVDepartureSensor(SensorEntity): for departure in data["departures"]: line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) departures.append( { ATTR_DEPARTURE: departure_time @@ -175,6 +183,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) self._attr_extra_state_attributes[ATTR_NEXT] = departures diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index a2f8838e2ea..8d7e3751c4c 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Hyperion server." } }, "auth": { diff --git a/homeassistant/components/ialarm/strings.json b/homeassistant/components/ialarm/strings.json index 1ac7a25e6f8..cb2c75d74a9 100644 --- a/homeassistant/components/ialarm/strings.json +++ b/homeassistant/components/ialarm/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of Antifurto365 iAlarm system." } } }, diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 34286ce49fa..5591980b2f1 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -62,7 +62,7 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) if data.get(CONF_VERIFY_SSL, True): - ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + ssl_context = client_context(ssl_cipher_list=SSLCipherList(ssl_cipher_list)) else: ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 2f42edb4bc1..de6091e3638 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -52,9 +52,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> iOSNotificationService | None: """Get the iOS notification service.""" - if "notify.ios" not in hass.config.components: + if "ios.notify" not in hass.config.components: # Need this to enable requirements checking in the app. - hass.config.components.add("notify.ios") + hass.config.components.add("ios.notify") if not ios.devices_with_push(hass): return None diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index f21dfe0cd09..266b32c5c31 100644 --- a/homeassistant/components/iotawatt/strings.json +++ b/homeassistant/components/iotawatt/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your IoTaWatt device." } }, "auth": { diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index e451ef882b4..ebdef4146e0 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 13e3fabfbff..765a3fc4d47 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -9,6 +9,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your Keenetic router." } } }, diff --git a/homeassistant/components/kmtronic/strings.json b/homeassistant/components/kmtronic/strings.json index 2a3a3a40687..6cecea12f22 100644 --- a/homeassistant/components/kmtronic/strings.json +++ b/homeassistant/components/kmtronic/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your KMtronic device." } } }, diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 60db7e95a65..a22a16a6e69 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 51431b317d6..7c7d53b33ac 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of the system hosting your Kodi server." } }, "discovery_confirm": { diff --git a/homeassistant/components/komfovent/__init__.py b/homeassistant/components/komfovent/__init__.py deleted file mode 100644 index 0366a429b21..00000000000 --- a/homeassistant/components/komfovent/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -"""The Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN - -PLATFORMS: list[Platform] = [Platform.CLIMATE] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Komfovent from a config entry.""" - host = entry.data[CONF_HOST] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - _, credentials = komfovent_api.get_credentials(host, username, password) - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - raise ConfigEntryNotReady(f"Unable to connect to {host}: {result}") - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (credentials, settings) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/komfovent/climate.py b/homeassistant/components/komfovent/climate.py deleted file mode 100644 index 2e51fddf4f2..00000000000 --- a/homeassistant/components/komfovent/climate.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Ventilation Units from Komfovent integration.""" -from __future__ import annotations - -import komfovent_api - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN - -HASS_TO_KOMFOVENT_MODES = { - HVACMode.COOL: komfovent_api.KomfoventModes.COOL, - HVACMode.HEAT_COOL: komfovent_api.KomfoventModes.HEAT_COOL, - HVACMode.OFF: komfovent_api.KomfoventModes.OFF, - HVACMode.AUTO: komfovent_api.KomfoventModes.AUTO, -} -KOMFOVENT_TO_HASS_MODES = {v: k for k, v in HASS_TO_KOMFOVENT_MODES.items()} - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Komfovent unit control.""" - credentials, settings = hass.data[DOMAIN][entry.entry_id] - async_add_entities([KomfoventDevice(credentials, settings)], True) - - -class KomfoventDevice(ClimateEntity): - """Representation of a ventilation unit.""" - - _attr_hvac_modes = list(HASS_TO_KOMFOVENT_MODES.keys()) - _attr_preset_modes = [mode.name for mode in komfovent_api.KomfoventPresets] - _attr_supported_features = ClimateEntityFeature.PRESET_MODE - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, - credentials: komfovent_api.KomfoventCredentials, - settings: komfovent_api.KomfoventSettings, - ) -> None: - """Initialize the ventilation unit.""" - self._komfovent_credentials = credentials - self._komfovent_settings = settings - - self._attr_unique_id = settings.serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, settings.serial_number)}, - model=settings.model, - name=settings.name, - serial_number=settings.serial_number, - sw_version=settings.version, - manufacturer="Komfovent", - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new target preset mode.""" - await komfovent_api.set_preset( - self._komfovent_credentials, - komfovent_api.KomfoventPresets[preset_mode], - ) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await komfovent_api.set_mode( - self._komfovent_credentials, HASS_TO_KOMFOVENT_MODES[hvac_mode] - ) - - async def async_update(self) -> None: - """Get the latest data.""" - result, status = await komfovent_api.get_unit_status( - self._komfovent_credentials - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS or not status: - self._attr_available = False - return - self._attr_available = True - self._attr_preset_mode = status.preset - self._attr_current_temperature = status.temp_extract - self._attr_hvac_mode = KOMFOVENT_TO_HASS_MODES[status.mode] diff --git a/homeassistant/components/komfovent/config_flow.py b/homeassistant/components/komfovent/config_flow.py deleted file mode 100644 index fb5390a30c6..00000000000 --- a/homeassistant/components/komfovent/config_flow.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Config flow for Komfovent integration.""" -from __future__ import annotations - -import logging -from typing import Any - -import komfovent_api -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER = "user" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME, default="user"): str, - vol.Required(CONF_PASSWORD): str, - } -) - -ERRORS_MAP = { - komfovent_api.KomfoventConnectionResult.NOT_FOUND: "cannot_connect", - komfovent_api.KomfoventConnectionResult.UNAUTHORISED: "invalid_auth", - komfovent_api.KomfoventConnectionResult.INVALID_INPUT: "invalid_input", -} - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Komfovent.""" - - VERSION = 1 - - def __return_error( - self, result: komfovent_api.KomfoventConnectionResult - ) -> FlowResult: - return self.async_show_form( - step_id=STEP_USER, - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": ERRORS_MAP.get(result, "unknown")}, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id=STEP_USER, data_schema=STEP_USER_DATA_SCHEMA - ) - - conf_host = user_input[CONF_HOST] - conf_username = user_input[CONF_USERNAME] - conf_password = user_input[CONF_PASSWORD] - - result, credentials = komfovent_api.get_credentials( - conf_host, conf_username, conf_password - ) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - result, settings = await komfovent_api.get_settings(credentials) - if result != komfovent_api.KomfoventConnectionResult.SUCCESS: - return self.__return_error(result) - - await self.async_set_unique_id(settings.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry(title=settings.name, data=user_input) diff --git a/homeassistant/components/komfovent/const.py b/homeassistant/components/komfovent/const.py deleted file mode 100644 index a7881a58c41..00000000000 --- a/homeassistant/components/komfovent/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the Komfovent integration.""" - -DOMAIN = "komfovent" diff --git a/homeassistant/components/komfovent/manifest.json b/homeassistant/components/komfovent/manifest.json deleted file mode 100644 index cbe00ef8dc5..00000000000 --- a/homeassistant/components/komfovent/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "komfovent", - "name": "Komfovent", - "codeowners": ["@ProstoSanja"], - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/komfovent", - "iot_class": "local_polling", - "requirements": ["komfovent-api==0.0.3"] -} diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 1c495ac9db9..adb1bfb6f09 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,13 +3,18 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging from typing import Any, TypeVar, cast from aiohttp.client_exceptions import ClientError -from pykoplenti import ApiClient, ApiException, AuthenticationException +from pykoplenti import ( + ApiClient, + ApiException, + AuthenticationException, + ExtendedApiClient, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, HomeAssistant @@ -51,7 +56,9 @@ class Plenticore: async def async_setup(self) -> bool: """Set up Plenticore API client.""" - self._client = ApiClient(async_get_clientsession(self.hass), host=self.host) + self._client = ExtendedApiClient( + async_get_clientsession(self.hass), host=self.host + ) try: await self._client.login(self.config_entry.data[CONF_PASSWORD]) except AuthenticationException as err: @@ -124,7 +131,7 @@ class DataUpdateCoordinatorMixin: async def async_read_data( self, module_id: str, data_id: str - ) -> dict[str, dict[str, str]] | None: + ) -> Mapping[str, Mapping[str, str]] | None: """Read data from Plenticore.""" if (client := self._plenticore.client) is None: return None @@ -190,7 +197,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): class ProcessDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]] + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]] ): """Implementation of PlenticoreUpdateCoordinator for process data.""" @@ -206,18 +213,19 @@ class ProcessDataUpdateCoordinator( return { module_id: { process_data.id: process_data.value - for process_data in fetched_data[module_id] + for process_data in fetched_data[module_id].values() } for module_id in fetched_data } class SettingDataUpdateCoordinator( - PlenticoreUpdateCoordinator[dict[str, dict[str, str]]], DataUpdateCoordinatorMixin + PlenticoreUpdateCoordinator[Mapping[str, Mapping[str, str]]], + DataUpdateCoordinatorMixin, ): """Implementation of PlenticoreUpdateCoordinator for settings data.""" - async def _async_update_data(self) -> dict[str, dict[str, str]]: + async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]: client = self._plenticore.client if not self._fetch or client is None: diff --git a/homeassistant/components/kostal_plenticore/manifest.json b/homeassistant/components/kostal_plenticore/manifest.json index 95f4a194977..d65368e7ee4 100644 --- a/homeassistant/components/kostal_plenticore/manifest.json +++ b/homeassistant/components/kostal_plenticore/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/kostal_plenticore", "iot_class": "local_polling", "loggers": ["kostal"], - "requirements": ["pykoplenti==1.0.0"] + "requirements": ["pykoplenti==1.2.2"] } diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f7bad638df4..ce18867511d 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -649,6 +649,39 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Day", + name="Battery Discharge Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Month", + name="Battery Discharge Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Year", + name="Battery Discharge Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="scb:statistic:EnergyFlow", + key="Statistic:EnergyDischarge:Total", + name="Battery Discharge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), PlenticoreSensorEntityDescription( module_id="scb:statistic:EnergyFlow", key="Statistic:EnergyDischargeGrid:Day", @@ -682,6 +715,52 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="pv_P", + name="Sum power of all PV DC inputs", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=True, + state_class=SensorStateClass.MEASUREMENT, + formatter="format_round", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Total", + name="Energy to Grid Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Year", + name="Energy to Grid Year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Month", + name="Energy to Grid Month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), + PlenticoreSensorEntityDescription( + module_id="_virt_", + key="Statistic:EnergyGrid:Day", + name="Energy to Grid Day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + formatter="format_energy", + ), ] diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 7355a60f5f0..40d38da55eb 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -209,7 +209,7 @@ class LaCrosseHumidity(LaCrosseSensor): _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:water-percent" + _attr_device_class = SensorDeviceClass.HUMIDITY @property def native_value(self) -> int | None: diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 4f7966ae90f..7d03ed2efaf 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.serialutil.SerialException) as err: + except (asyncio.TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 27231dc7b92..db265449f37 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -33,5 +33,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): try: async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) - except (FileNotFoundError, serial.serialutil.SerialException) as err: + except (FileNotFoundError, serial.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 9fd407b1636..398fcb95872 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.15.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 6ecd4ed636e..440ec427f8d 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.15.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.17.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json index 8c6a9909ff5..ee16a39350c 100644 --- a/homeassistant/components/lg_soundbar/strings.json +++ b/homeassistant/components/lg_soundbar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LG Soundbar." } } }, diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c327081fabd..21f3b3fe52b 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your LIFX device." } }, "pick_device": { diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 78cccde5890..3bb3797c284 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -500,6 +500,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: assert (rgb_color := params.pop(ATTR_RGB_COLOR)) is not None if ColorMode.RGBW in supported_color_modes: @@ -515,6 +523,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) if ColorMode.HS in supported_color_modes: @@ -529,6 +545,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) @@ -542,6 +565,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes ): @@ -558,6 +589,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) # If white is set to True, set it to the light's brightness # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 433da53a570..fb7a1539944 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -252,8 +252,9 @@ turn_on: - light.ColorMode.RGBWW selector: color_temp: - min_mireds: 153 - max_mireds: 500 + unit: "mired" + min: 153 + max: 500 kelvin: filter: attribute: @@ -266,11 +267,10 @@ turn_on: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: @@ -637,11 +637,10 @@ toggle: - light.ColorMode.RGBWW advanced: true selector: - number: + color_temp: + unit: "kelvin" min: 2000 max: 6500 - step: 100 - unit_of_measurement: K brightness: filter: attribute: diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 136880257ce..8525bb9ff17 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pylitejet"], "quality_scale": "platinum", - "requirements": ["pylitejet==0.5.0"] + "requirements": ["pylitejet==0.6.0"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index d7b16ee3bef..f5a24e07b0c 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 4c3a8e10a62..335a89eab3c 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.1.0"] + "requirements": ["ical==6.1.1"] } diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index ed7e2070055..ca91236a77c 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -33,7 +33,6 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) @@ -75,48 +74,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, _async_unlock + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" ) component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, _async_lock + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" ) component.async_register_entity_service( - SERVICE_OPEN, LOCK_SERVICE_SCHEMA, _async_open, [LockEntityFeature.OPEN] + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], ) return True -@callback -def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]: - data = remove_entity_service_fields(service_call) - code: str = data.pop(ATTR_CODE, "") - if not code: - code = entity._lock_option_default_code # pylint: disable=protected-access - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - if code: - data[ATTR_CODE] = code - return data - - -async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: - """Lock the lock.""" - await entity.async_lock(**_add_default_code(entity, service_call)) - - -async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: - """Unlock the lock.""" - await entity.async_unlock(**_add_default_code(entity, service_call)) - - -async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: - """Open the door latch.""" - await entity.async_open(**_add_default_code(entity, service_call)) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[LockEntity] = hass.data[DOMAIN] @@ -149,6 +121,21 @@ class LockEntity(Entity): _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None + @final + @callback + def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]: + """Add default lock code.""" + code: str = data.pop(ATTR_CODE, "") + if not code: + code = self._lock_option_default_code + if self.code_format_cmp and not self.code_format_cmp.match(code): + raise ValueError( + f"Code '{code}' for locking {self.entity_id} doesn't match pattern {self.code_format}" + ) + if code: + data[ATTR_CODE] = code + return data + @property def changed_by(self) -> str | None: """Last change triggered by.""" @@ -193,6 +180,11 @@ class LockEntity(Entity): """Return true if the lock is jammed (incomplete locking).""" return self._attr_is_jammed + @final + async def async_handle_lock_service(self, **kwargs: Any) -> None: + """Add default code and lock.""" + await self.async_lock(**self.add_default_code(kwargs)) + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -201,6 +193,11 @@ class LockEntity(Entity): """Lock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs)) + @final + async def async_handle_unlock_service(self, **kwargs: Any) -> None: + """Add default code and unlock.""" + await self.async_unlock(**self.add_default_code(kwargs)) + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" raise NotImplementedError() @@ -209,6 +206,11 @@ class LockEntity(Entity): """Unlock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs)) + @final + async def async_handle_open_service(self, **kwargs: Any) -> None: + """Add default code and open.""" + await self.async_open(**self.add_default_code(kwargs)) + def open(self, **kwargs: Any) -> None: """Open the door latch.""" raise NotImplementedError() diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index e1641451221..d935ad9bff5 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage -from homeassistant.util.yaml import Secrets, load_yaml +from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( CONF_ICON, @@ -201,7 +201,9 @@ class LovelaceYAML(LovelaceConfig): is_updated = self._cache is not None try: - config = load_yaml(self.path, Secrets(Path(self.hass.config.config_dir))) + config = load_yaml_dict( + self.path, Secrets(Path(self.hass.config.config_dir)) + ) except FileNotFoundError: raise ConfigNotFound from None diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index b5ec175d1c9..0fb906f097f 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -11,6 +11,9 @@ "description": "Enter the IP address of the device.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Lutron Caseta Smart Bridge." } }, "link": { diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index d0bad55ff14..f01e4c4fe55 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -324,6 +324,15 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): "Could not find target_temp_low and/or target_temp_high in" " arguments" ) + + # If the device supports "Auto" mode, don't pass the mode when setting the + # temperature + mode = ( + None + if device.changeableValues.mode == LYRIC_HVAC_MODE_HEAT_COOL + else HVAC_MODES[device.changeableValues.heatCoolMode] + ) + _LOGGER.debug("Set temperature: %s - %s", target_temp_low, target_temp_high) try: await self._update_thermostat( @@ -331,7 +340,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): device, coolSetpoint=target_temp_high, heatSetpoint=target_temp_low, - mode=HVAC_MODES[device.changeableValues.heatCoolMode], + mode=mode, ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 679abfd3164..623d0f06295 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -13,13 +13,10 @@ from aiohttp.web_exceptions import HTTPNotFound from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView +from homeassistant.config import config_per_platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - discovery, -) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index ddda50aa8b2..44a65a2de59 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -7,7 +7,7 @@ import logging import mimetypes import os import re -from typing import NewType, TypedDict +from typing import Final, NewType, Required, TypedDict import aiofiles.os from nio import AsyncClient, Event, MatrixRoom @@ -49,11 +49,11 @@ _LOGGER = logging.getLogger(__name__) SESSION_FILE = ".matrix.conf" -CONF_HOMESERVER = "homeserver" -CONF_ROOMS = "rooms" -CONF_COMMANDS = "commands" -CONF_WORD = "word" -CONF_EXPRESSION = "expression" +CONF_HOMESERVER: Final = "homeserver" +CONF_ROOMS: Final = "rooms" +CONF_COMMANDS: Final = "commands" +CONF_WORD: Final = "word" +CONF_EXPRESSION: Final = "expression" CONF_USERNAME_REGEX = "^@[^:]*:.*" CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" @@ -78,10 +78,10 @@ RoomAnyID = RoomID | RoomAlias class ConfigCommand(TypedDict, total=False): """Corresponds to a single COMMAND_SCHEMA.""" - name: str # CONF_NAME - rooms: list[RoomID] | None # CONF_ROOMS - word: WordCommand | None # CONF_WORD - expression: ExpressionCommand | None # CONF_EXPRESSION + name: Required[str] # CONF_NAME + rooms: list[RoomID] # CONF_ROOMS + word: WordCommand # CONF_WORD + expression: ExpressionCommand # CONF_EXPRESSION COMMAND_SCHEMA = vol.All( @@ -223,15 +223,15 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) - self._word_commands[room_id][word_command] = command # type: ignore[index] + self._word_commands[room_id][word_command] = command else: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) @@ -263,7 +263,7 @@ class MatrixBot: # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] + match = command[CONF_EXPRESSION].match(message.body) if not match: continue message_data = { diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 2831ebe9a38..5690996841d 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -97,22 +97,23 @@ class MatterAdapter: self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_added_callback, EventType.ENDPOINT_ADDED + callback=endpoint_added_callback, event_filter=EventType.ENDPOINT_ADDED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - endpoint_removed_callback, EventType.ENDPOINT_REMOVED + callback=endpoint_removed_callback, + event_filter=EventType.ENDPOINT_REMOVED, ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_removed_callback, EventType.NODE_REMOVED + callback=node_removed_callback, event_filter=EventType.NODE_REMOVED ) ) self.config_entry.async_on_unload( self.matter_client.subscribe_events( - node_added_callback, EventType.NODE_ADDED + callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 7b4b7d35b7f..227d0c73e89 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -1,9 +1,9 @@ """Handle websocket api for Matter.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any +from typing import Any, Concatenate, ParamSpec from matter_server.common.errors import MatterError import voluptuous as vol @@ -15,6 +15,8 @@ from homeassistant.core import HomeAssistant, callback from .adapter import MatterAdapter from .helpers import get_matter +_P = ParamSpec("_P") + ID = "id" TYPE = "type" @@ -28,12 +30,19 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_set_wifi_credentials) -def async_get_matter_adapter(func: Callable) -> Callable: +def async_get_matter_adapter( + func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to get the MatterAdapter.""" @wraps(func) async def _get_matter( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide the Matter client to the function.""" matter = get_matter(hass) @@ -43,7 +52,15 @@ def async_get_matter_adapter(func: Callable) -> Callable: return _get_matter -def async_handle_failed_command(func: Callable) -> Callable: +def async_handle_failed_command( + func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate function to handle MatterError and send relevant error.""" @wraps(func) @@ -51,8 +68,8 @@ def async_handle_failed_command(func: Callable) -> Callable: hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - *args: Any, - **kwargs: Any, + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle MatterError within function and send relevant error.""" try: diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c971bf8465e..e1d004a15c8 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -115,8 +115,9 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, + should_poll=schema.should_poll, ) - # prevent re-discovery of the same attributes + # prevent re-discovery of the primary attribute if not allowed if not schema.allow_multi: - discovered_attributes.update(attributes_to_watch) + discovered_attributes.update(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7e7b7a688df..de6e6ff83c2 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -5,6 +5,7 @@ from abc import abstractmethod from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass +from datetime import datetime import logging from typing import TYPE_CHECKING, Any, cast @@ -12,9 +13,10 @@ from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.event import async_call_later from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -27,6 +29,13 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +# For some manually polled values (e.g. custom clusters) we perform +# an additional poll as soon as a secondary value changes. +# For example update the energy consumption meter when a relay is toggled +# of an energy metering powerplug. The below constant defined the delay after +# which we poll the primary value (debounced). +EXTRA_POLL_DELAY = 3.0 + @dataclass class MatterEntityDescription(EntityDescription): @@ -39,7 +48,6 @@ class MatterEntityDescription(EntityDescription): class MatterEntity(Entity): """Entity class for Matter devices.""" - _attr_should_poll = False _attr_has_entity_name = True def __init__( @@ -71,6 +79,8 @@ class MatterEntity(Entity): identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) self._attr_available = self._endpoint.node.available + self._attr_should_poll = entity_info.should_poll + self._extra_poll_timer_unsub: CALLBACK_TYPE | None = None async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -110,15 +120,35 @@ class MatterEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() for unsub in self._unsubscribes: with suppress(ValueError): # suppress ValueError to prevent race conditions unsub() + async def async_update(self) -> None: + """Call when the entity needs to be updated.""" + # manually poll/refresh the primary value + await self.matter_client.refresh_attribute( + self._endpoint.node.node_id, + self.get_matter_attribute_path(self._entity_info.primary_attribute), + ) + self._update_from_device() + @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: - """Call on update.""" + """Call on update from the device.""" self._attr_available = self._endpoint.node.available + if self._attr_should_poll: + # secondary attribute updated of a polled primary value + # enforce poll of the primary value a few seconds later + if self._extra_poll_timer_unsub: + self._extra_poll_timer_unsub() + self._extra_poll_timer_unsub = async_call_later( + self.hass, EXTRA_POLL_DELAY, self._do_extra_poll + ) + return self._update_from_device() self.async_write_ha_state() @@ -145,3 +175,9 @@ class MatterEntity(Entity): return create_attribute_path( self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) + + @callback + def _do_extra_poll(self, called_at: datetime) -> None: + """Perform (extra) poll of primary value.""" + # scheduling the regulat update is enough to perform a poll/refresh + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index dcd6a30ee1f..446d5dc3591 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -94,7 +94,7 @@ def get_node_from_device_entry( ) if device_id_full is None: - raise ValueError(f"Device {device.id} is not a Matter device") + return None device_id = device_id_full.lstrip(device_id_type_prefix) matter_client = matter.matter_client diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 8491f58e387..dd29638f765 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -89,10 +89,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.LockDoor(code_bytes) @@ -100,10 +97,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None if self.supports_unbolt: # if the lock reports it has separate unbolt support, @@ -119,10 +113,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 174ebb1cab9..f350cda9227 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==4.0.2"] + "requirements": ["python-matter-server==5.0.0"] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 34447751797..5f47f73b139 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -50,6 +50,9 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type + # [optional] bool to specify if this primary value should be polled + should_poll: bool + @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -106,3 +109,6 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False + + # [optional] bool to specify if this primary value should be polled + should_poll: bool = False diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5021ed7fa0d..6262eb253aa 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue +from matter_server.client.models.clusters import EveEnergyCluster from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +19,10 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfVolumeFlowRate, @@ -48,7 +53,6 @@ class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescrip class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT entity_description: MatterSensorEntityDescription @callback @@ -72,6 +76,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), @@ -83,6 +88,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), @@ -94,6 +100,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", measurement_to_ha=lambda x: x / 10, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), @@ -105,6 +112,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, measurement_to_ha=lambda x: x / 100, + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=( @@ -118,6 +126,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), @@ -131,8 +140,71 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), + state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWatt", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Watt,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Voltage,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattAccumulated", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.WattAccumulated,), + should_poll=True, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveEnergySensorWattCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveEnergyCluster.Attributes.Current,), + # Add OnOff Attribute as optional attribute to poll + # the primary value when the relay is toggled + optional_attributes=(clusters.OnOff.Attributes.OnOff,), + should_poll=True, + ), ] diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 5f007f3a8e5..d1ed5cafcbf 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone import voluptuous as vol @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -66,7 +66,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" conf = entry.data - mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + try: + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + except ClientResponseError as ex: + if isinstance(ex, ClientResponseError) and ex.code == 401: + raise ConfigEntryAuthFailed from ex + raise ConfigEntryNotReady from ex + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady from ex + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -162,17 +170,13 @@ async def mel_devices_setup( ) -> dict[str, list[MelCloudDevice]]: """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) - try: - async with asyncio.timeout(10): - all_devices = await get_devices( - token, - session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), - ) - except (asyncio.TimeoutError, ClientConnectionError) as ex: - raise ConfigEntryNotReady() from ex - + async with asyncio.timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 0ff17ea751a..b19e268a4c3 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from http import HTTPStatus +import logging +from typing import Any from aiohttp import ClientError, ClientResponseError import pymelcloud @@ -11,12 +14,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResultType +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_create_import_issue( hass: HomeAssistant, source: str, issue: str, success: bool = False @@ -56,6 +61,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + entry: config_entries.ConfigEntry | None = None + async def _create_entry(self, username: str, token: str): """Register new entry.""" await self.async_set_unique_id(username) @@ -126,3 +133,67 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if result["type"] == FlowResultType.CREATE_ENTRY: await async_create_import_issue(self.hass, self.context["source"], "", True) return result + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with MELCloud.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle re-authentication with MELCloud.""" + errors: dict[str, str] = {} + + if user_input is not None and self.entry: + aquired_token, errors = await self.async_reauthenticate_client(user_input) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={CONF_TOKEN: aquired_token}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_reauthenticate_client( + self, user_input: dict[str, Any] + ) -> tuple[str | None, dict[str, str]]: + """Reauthenticate with MELCloud.""" + errors: dict[str, str] = {} + acquired_token = None + + try: + async with asyncio.timeout(10): + acquired_token = await pymelcloud.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ) + except (ClientResponseError, AttributeError) as err: + if isinstance(err, ClientResponseError) and err.status in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + errors["base"] = "invalid_auth" + elif isinstance(err, AttributeError) and err.name == "get": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ( + asyncio.TimeoutError, + ClientError, + ): + errors["base"] = "cannot_connect" + + return acquired_token, errors diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index eefd5a07a8d..3abb30bf9ac 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -8,6 +8,14 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Melcloud integration needs to re-authenticate your connection details", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -16,6 +24,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } }, diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 3b6bb9c3518..567788ec479 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/meteo_france", "iot_class": "cloud_polling", "loggers": ["meteofrance_api"], - "requirements": ["meteofrance-api==1.2.0"] + "requirements": ["meteofrance-api==1.3.0"] } diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 4354b9b06bd..8407dd14a6e 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -25,9 +25,11 @@ CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" WIFIWAVE2: Final = "wifiwave2" +WIFI: Final = "wifi" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" IS_WIFIWAVE2: Final = "is_wifiwave2" +IS_WIFI: Final = "is_wifi" MIKROTIK_SERVICES: Final = { @@ -38,9 +40,11 @@ MIKROTIK_SERVICES: Final = { INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", WIFIWAVE2: "/interface/wifiwave2/registration-table/print", + WIFI: "/interface/wifi/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", IS_WIFIWAVE2: "/interface/wifiwave2/print", + IS_WIFI: "/interface/wifi/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 9e0a610c770..af7dfb2ab2c 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,10 +31,12 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFI, IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFI, WIFIWAVE2, WIRELESS, ) @@ -60,6 +62,7 @@ class MikrotikData: self.support_capsman: bool = False self.support_wireless: bool = False self.support_wifiwave2: bool = False + self.support_wifi: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -101,6 +104,7 @@ class MikrotikData: self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) + self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -128,6 +132,9 @@ class MikrotikData: elif self.support_wifiwave2: _LOGGER.debug("Hub supports wifiwave2 Interface") device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) + elif self.support_wifi: + _LOGGER.debug("Hub supports wifi Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFI) if not device_list or self.force_dhcp: device_list = self.all_devices diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index cb0ba4522bf..7bb78eb05e7 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] } diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 4e5ab9290f0..0e2debda33e 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -30,13 +30,16 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - # Check and create API instance. + # Create API instance. + api = MinecraftServer( + hass, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. try: - api = await hass.async_add_executor_job( - MinecraftServer, - entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), - entry.data[CONF_ADDRESS], - ) + await api.async_initialize() except MinecraftServerAddressError as error: raise ConfigEntryError( f"Server address in configuration entry is invalid: {error}" @@ -102,9 +105,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_data = config_entry.data # Migrate config entry. + address = config_data[CONF_HOST] + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = config_data[CONF_HOST] - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() host_only_lookup_success = True except MinecraftServerAddressError as error: host_only_lookup_success = False @@ -114,9 +119,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if not host_only_lookup_success: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() except MinecraftServerAddressError as error: _LOGGER.exception( "Can't migrate configuration entry due to error while parsing server address, try again later: %s", diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index 4ab7865f369..fc872d37bde 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -9,6 +9,8 @@ from dns.resolver import LifetimeTimeout from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from homeassistant.core import HomeAssistant + _LOGGER = logging.getLogger(__name__) LOOKUP_TIMEOUT: float = 10 @@ -52,35 +54,51 @@ class MinecraftServerConnectionError(Exception): """Raised when no data can be fechted from the server.""" +class MinecraftServerNotInitializedError(Exception): + """Raised when APIs are used although server instance is not initialized yet.""" + + class MinecraftServer: """Minecraft Server wrapper class for 3rd party library mcstatus.""" - _server: BedrockServer | JavaServer + _server: BedrockServer | JavaServer | None - def __init__(self, server_type: MinecraftServerType, address: str) -> None: + def __init__( + self, hass: HomeAssistant, server_type: MinecraftServerType, address: str + ) -> None: """Initialize server instance.""" + self._server = None + self._hass = hass + self._server_type = server_type + self._address = address + + async def async_initialize(self) -> None: + """Perform async initialization of server instance.""" try: - if server_type == MinecraftServerType.JAVA_EDITION: - self._server = JavaServer.lookup(address, timeout=LOOKUP_TIMEOUT) + if self._server_type == MinecraftServerType.JAVA_EDITION: + self._server = await JavaServer.async_lookup(self._address) else: - self._server = BedrockServer.lookup(address, timeout=LOOKUP_TIMEOUT) + self._server = await self._hass.async_add_executor_job( + BedrockServer.lookup, self._address + ) except (ValueError, LifetimeTimeout) as error: raise MinecraftServerAddressError( - f"Lookup of '{address}' failed: {self._get_error_message(error)}" + f"Lookup of '{self._address}' failed: {self._get_error_message(error)}" ) from error self._server.timeout = DATA_UPDATE_TIMEOUT - self._address = address _LOGGER.debug( - "%s server instance created with address '%s'", server_type, address + "%s server instance created with address '%s'", + self._server_type, + self._address, ) async def async_is_online(self) -> bool: """Check if the server is online, supporting both Java and Bedrock Edition servers.""" try: await self.async_get_data() - except MinecraftServerConnectionError: + except (MinecraftServerConnectionError, MinecraftServerNotInitializedError): return False return True @@ -89,6 +107,9 @@ class MinecraftServer: """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" status_response: BedrockStatusResponse | JavaStatusResponse + if self._server is None: + raise MinecraftServerNotInitializedError() + try: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) except OSError as error: diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index f064a4ac1ef..045133421fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -35,10 +35,10 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. for server_type in MinecraftServerType: + api = MinecraftServer(self.hass, server_type, address) + try: - api = await self.hass.async_add_executor_job( - MinecraftServer, server_type, address - ) + await api.async_initialize() except MinecraftServerAddressError: pass else: diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f7a60318c64..e498375cafc 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -7,7 +7,12 @@ import logging from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData +from .api import ( + MinecraftServer, + MinecraftServerConnectionError, + MinecraftServerData, + MinecraftServerNotInitializedError, +) SCAN_INTERVAL = timedelta(seconds=60) @@ -32,5 +37,8 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Get updated data from the server.""" try: return await self._api.async_get_data() - except MinecraftServerConnectionError as error: + except ( + MinecraftServerConnectionError, + MinecraftServerNotInitializedError, + ) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 14f8b59ddee..74a1de48c0a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -63,6 +63,18 @@ from .const import ( # noqa: F401 CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -100,7 +112,6 @@ from .const import ( # noqa: F401 CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, @@ -123,6 +134,7 @@ from .const import ( # noqa: F401 from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -179,9 +191,10 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_SWAP, default=CONF_SWAP_NONE): vol.In( + vol.Optional( + CONF_SWAP, + ): vol.In( [ - CONF_SWAP_NONE, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, @@ -265,6 +278,26 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( + vol.All( + { + CONF_ADDRESS: cv.positive_int, + CONF_FAN_MODE_VALUES: { + vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, + vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, + vol.Optional(CONF_FAN_MODE_AUTO): cv.positive_int, + vol.Optional(CONF_FAN_MODE_LOW): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MEDIUM): cv.positive_int, + vol.Optional(CONF_FAN_MODE_HIGH): cv.positive_int, + vol.Optional(CONF_FAN_MODE_TOP): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MIDDLE): cv.positive_int, + vol.Optional(CONF_FAN_MODE_FOCUS): cv.positive_int, + vol.Optional(CONF_FAN_MODE_DIFFUSE): cv.positive_int, + }, + }, + duplicate_fan_mode_validator, + ), + ), } ), ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index edfca94979e..1c7c8f65140 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -24,10 +24,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -55,13 +56,13 @@ from .const import ( CONF_STATE_ON, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, + MODBUS_DOMAIN, SIGNAL_START_ENTITY, SIGNAL_STOP_ENTITY, DataType, @@ -75,8 +76,34 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] + ) -> None: """Initialize the Modbus binary sensor.""" + + if CONF_LAZY_ERROR in entry: + async_create_issue( + hass, + MODBUS_DOMAIN, + "removed_lazy_error_count", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="removed_lazy_error_count", + translation_placeholders={ + "config_key": "lazy_error_count", + "integration": MODBUS_DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + + _LOGGER.warning( + "`lazy_error_count`: is deprecated and will be removed in version 2024.7" + ) + self._hub = hub self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) @@ -93,8 +120,6 @@ class BasePlatform(Entity): self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None - self._lazy_error_count = entry[CONF_LAZY_ERROR] - self._lazy_errors = self._lazy_error_count def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: @@ -154,12 +179,10 @@ class BasePlatform(Entity): class BaseStructPlatform(BasePlatform, RestoreEntity): """Base class representing a sensor/climate.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._swap = config[CONF_SWAP] - if self._swap == CONF_SWAP_NONE: - self._swap = None self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] @@ -250,10 +273,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( @@ -346,15 +369,10 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._attr_is_on = bool(result.bits[0] & 1) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 39174ae8931..6c0f6422df2 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusBinarySensor(hub, entry, slave_count) + sensor = ModbusBinarySensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -64,12 +64,18 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any], slave_count: int) -> None: + def __init__( + self, + hass: HomeAssistant, + hub: ModbusHub, + entry: dict[str, Any], + slave_count: int, + ) -> None: """Initialize the Modbus binary sensor.""" self._count = slave_count + 1 self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._result: list[int] = [] - super().__init__(hub, entry) + super().__init__(hass, hub, entry) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -109,14 +115,9 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._result = [] else: - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._result = result.bits diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index df2983e9070..76132014413 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -6,6 +6,16 @@ import struct from typing import Any, cast from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -31,6 +41,18 @@ from .const import ( CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -67,7 +89,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hub, entity)) + entities.append(ModbusThermostat(hass, hub, entity)) async_add_entities(entities) @@ -79,11 +101,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus thermostat.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._target_temperature_write_registers = config[ CONF_TARGET_TEMP_WRITE_REGISTERS @@ -137,6 +160,42 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_FAN_MODE_REGISTER in config: + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + mode_config = config[CONF_FAN_MODE_REGISTER] + self._fan_mode_register = mode_config[CONF_ADDRESS] + self._attr_fan_modes = cast(list[str], []) + self._attr_fan_mode = None + self._fan_mode_mapping_to_modbus: dict[str, int] = {} + self._fan_mode_mapping_from_modbus: dict[int, str] = {} + mode_value_config = mode_config[CONF_FAN_MODE_VALUES] + + for fan_mode_kw, fan_mode in ( + (CONF_FAN_MODE_ON, FAN_ON), + (CONF_FAN_MODE_OFF, FAN_OFF), + (CONF_FAN_MODE_AUTO, FAN_AUTO), + (CONF_FAN_MODE_LOW, FAN_LOW), + (CONF_FAN_MODE_MEDIUM, FAN_MEDIUM), + (CONF_FAN_MODE_HIGH, FAN_HIGH), + (CONF_FAN_MODE_TOP, FAN_TOP), + (CONF_FAN_MODE_MIDDLE, FAN_MIDDLE), + (CONF_FAN_MODE_FOCUS, FAN_FOCUS), + (CONF_FAN_MODE_DIFFUSE, FAN_DIFFUSE), + ): + if fan_mode_kw in mode_value_config: + value = mode_value_config[fan_mode_kw] + self._fan_mode_mapping_from_modbus[value] = fan_mode + self._fan_mode_mapping_to_modbus[fan_mode] = value + self._attr_fan_modes.append(fan_mode) + + else: + # No HVAC modes defined + self._fan_mode_register = None + self._attr_fan_mode = FAN_AUTO + self._attr_fan_modes = [FAN_AUTO] + if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] @@ -193,6 +252,21 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): await self.async_update() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + if self._fan_mode_register is not None: + # Write a value to the mode register for the desired mode. + value = self._fan_mode_mapping_to_modbus[fan_mode] + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + + await self.async_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = ( @@ -254,7 +328,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._input_type, self._address ) - # Read the mode register if defined + # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True @@ -268,7 +342,17 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break - # Read th on/off register if defined. If the value in this + # Read the Fan mode register if defined + if self._fan_mode_register is not None: + fan_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._fan_mode_register, raw=True + ) + + # Translate the value received + if fan_mode is not None: + self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + + # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value # in the mode register. if self._hvac_onoff_register is not None: @@ -288,15 +372,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return -1 - self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 - self._lazy_errors = self._lazy_error_count - if raw: # Return the raw value read from the register, do not change # the object's state diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index a52f8ccfc97..e536a31c4f6 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -45,13 +45,23 @@ CONF_STEP = "temp_step" CONF_STOPBITS = "stopbits" CONF_SWAP = "swap" CONF_SWAP_BYTE = "byte" -CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers" +CONF_FAN_MODE_REGISTER = "fan_mode_register" +CONF_FAN_MODE_ON = "state_fan_on" +CONF_FAN_MODE_OFF = "state_fan_off" +CONF_FAN_MODE_LOW = "state_fan_low" +CONF_FAN_MODE_MEDIUM = "state_fan_medium" +CONF_FAN_MODE_HIGH = "state_fan_high" +CONF_FAN_MODE_AUTO = "state_fan_auto" +CONF_FAN_MODE_TOP = "state_fan_top" +CONF_FAN_MODE_MIDDLE = "state_fan_middle" +CONF_FAN_MODE_FOCUS = "state_fan_focus" +CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse" +CONF_FAN_MODE_VALUES = "values" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" -CONF_HVAC_MODE_VALUES = "values" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" @@ -60,6 +70,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_HVAC_MODE_VALUES = "values" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VIRTUAL_COUNT = "virtual_count" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 27f9cb1fc18..072f1bb3d93 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -51,7 +51,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hub, cover)) + covers.append(ModbusCover(hass, hub, cover)) async_add_entities(covers) @@ -63,11 +63,12 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus cover.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._state_closed = config[CONF_STATE_CLOSED] self._state_closing = config[CONF_STATE_CLOSING] self._state_open = config[CONF_STATE_OPEN] @@ -142,14 +143,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._address, 1, self._input_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a986b243c1b..e5006b66f81 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_FANS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hub, entry)) + fans.append(ModbusFan(hass, hub, entry)) async_add_entities(fans) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 2e5ac62be21..acc01f39b46 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -29,7 +29,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hub, entry)) + lights.append(ModbusLight(hass, hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 52aa37535d6..c015d117b13 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -53,7 +52,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusRegisterSensor(hub, entry, slave_count) + sensor = ModbusRegisterSensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -65,12 +64,13 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any], slave_count: int, ) -> None: """Initialize the modbus register sensor.""" - super().__init__(hub, entry) + super().__init__(hass, hub, entry) if slave_count: self._count = self._count * (slave_count + 1) self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None @@ -114,13 +114,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._slave, self._address, self._count, self._input_type ) if raw_result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - self._cancel_call = async_call_later( - self.hass, timedelta(seconds=1), self.async_update - ) - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._attr_native_value = None if self._coordinator: @@ -142,7 +135,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): else: self._attr_native_value = result self._attr_available = self._attr_native_value is not None - self._lazy_errors = self._lazy_error_count self.async_write_ha_state() diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 5f45d0df596..c549b59bf8f 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,9 +70,13 @@ } }, "issues": { + "removed_lazy_error_count": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" + }, "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + "description": "Please remove the `{config_key}` key from the {integration} entry in your `configuration.yaml` file and restart Home Assistant to fix this issue. All errors will be reported, as `lazy_error_count` is accepted but ignored." }, "deprecated_retry_on_empty": { "title": "`{config_key}` configuration key is being removed", diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index beb84096006..0c955ea409d 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_SWITCHES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hub, entry)) + switches.append(ModbusSwitch(hass, hub, entry)) async_add_entities(switches) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 52919a24ac7..7dc5a91a2fa 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -26,13 +26,16 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, @@ -115,8 +118,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) - swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + swap_type = config.get(CONF_SWAP) for entry in ( (count, validator.count, CONF_COUNT), (structure, validator.structure, CONF_STRUCTURE), @@ -136,9 +139,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: ) raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: + if swap_type: swap_type_validator = { - CONF_SWAP_NONE: validator.swap_byte, CONF_SWAP_BYTE: validator.swap_byte, CONF_SWAP_WORD: validator.swap_word, CONF_SWAP_WORD_BYTE: validator.swap_word, @@ -266,12 +268,31 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_OFF]) inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) addr += "_" + str(inx) - if addr in addresses: - err = ( - f"Modbus {component}/{name} address {addr} is duplicate, second" - " entry not loaded!" - ) - _LOGGER.warning(err) + entry_addrs: set[str] = set() + entry_addrs.add(addr) + + if CONF_TARGET_TEMP in entry: + a = str(entry[CONF_TARGET_TEMP]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_HVAC_MODE_REGISTER in entry: + a = str(entry[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_FAN_MODE_REGISTER in entry: + a = str(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + + dup_addrs = entry_addrs.intersection(addresses) + + if len(dup_addrs) > 0: + for addr in dup_addrs: + err = ( + f"Modbus {component}/{name} address {addr} is duplicate, second" + " entry not loaded!" + ) + _LOGGER.warning(err) errors.append(index) elif name in names: err = ( @@ -282,7 +303,7 @@ def duplicate_entity_validator(config: dict) -> dict: errors.append(index) else: names.add(name) - addresses.add(addr) + addresses.update(entry_addrs) for i in reversed(errors): del config[hub_index][conf_key][i] @@ -301,11 +322,11 @@ def duplicate_modbus_validator(config: list) -> list: else: host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: - err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + err = f"Modbus {name} contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) errors.append(index) elif name in names: - err = f"Modbus {name}  is duplicate, second entry not loaded!" + err = f"Modbus {name} is duplicate, second entry not loaded!" _LOGGER.warning(err) errors.append(index) else: @@ -315,3 +336,20 @@ def duplicate_modbus_validator(config: list) -> list: for i in reversed(errors): del config[i] return config + + +def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: + """Control modbus climate fan mode values for duplicates.""" + fan_modes: set[int] = set() + errors = [] + for key, value in config[CONF_FAN_MODE_VALUES].items(): + if value in fan_modes: + wrn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(wrn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 9d5a3c32235..e6bcff715b8 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -12,10 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import ( ModernFormsDataUpdateCoordinator, diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index dd47ef721af..e6d0f6a2206 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -6,6 +6,9 @@ "description": "Set up your Modern Forms fan to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Modern Forms fan." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 8acc88d8314..5cdca72fa55 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -27,6 +27,7 @@ async def async_setup_entry( Alpha2IODeviceBatterySensor(coordinator, io_device_id) for io_device_id, io_device in coordinator.data["io_devices"].items() if io_device["_HEATAREA_ID"] + and io_device["_HEATAREA_ID"] in coordinator.data["heat_areas"] ) diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index e41c6b041f6..2c2e44f451d 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( Alpha2HeatControlValveOpeningSensor(coordinator, heat_control_id) for heat_control_id, heat_control in coordinator.data["heat_controls"].items() if heat_control["INUSE"] - and heat_control["_HEATAREA_ID"] + and heat_control["_HEATAREA_ID"] in coordinator.data["heat_areas"] and heat_control.get("ACTOR_PERCENT") is not None ) diff --git a/homeassistant/components/moehlenhoff_alpha2/strings.json b/homeassistant/components/moehlenhoff_alpha2/strings.json index 3347b2f318c..d15ec9f89eb 100644 --- a/homeassistant/components/moehlenhoff_alpha2/strings.json +++ b/homeassistant/components/moehlenhoff_alpha2/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Möhlenhoff Alpha2 system." } } }, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c8da14e67e6..4e8cf0f4129 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -380,7 +380,11 @@ class MqttCover(MqttEntity, CoverEntity): else STATE_OPEN ) else: - state = STATE_CLOSED if self.state == STATE_CLOSING else STATE_OPEN + state = ( + STATE_CLOSED + if self.state in [STATE_CLOSED, STATE_CLOSING] + else STATE_OPEN + ) elif payload == self._config[CONF_STATE_OPENING]: state = STATE_OPENING elif payload == self._config[CONF_STATE_CLOSING]: diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0e9e7d708e9..24783e171c8 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -31,10 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import subscription from .config import MQTT_RW_SCHEMA @@ -553,8 +553,6 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ - self._valid_preset_mode_or_raise(preset_mode) - mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode) await self.async_publish( diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2a2a262be36..3479f1611d8 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import suppress import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -367,13 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: - self._attr_brightness = min( - int( - brightness # type: ignore[operator] - / float(self._config[CONF_BRIGHTNESS_SCALE]) - * 255 - ), - 255, + if TYPE_CHECKING: + assert isinstance(brightness, float) + self._attr_brightness = color_util.value_to_brightness( + (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness ) else: _LOGGER.debug( @@ -409,6 +406,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): values["color_temp"], self.entity_id, ) + # Allow to switch back to color_temp + if "color" not in values: + self._attr_hs_color = None if self.supported_features and LightEntityFeature.EFFECT: with suppress(KeyError): @@ -594,13 +594,12 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) if ATTR_BRIGHTNESS in kwargs and self._config[CONF_BRIGHTNESS]: - brightness_normalized = kwargs[ATTR_BRIGHTNESS] / DEFAULT_BRIGHTNESS_SCALE - brightness_scale = self._config[CONF_BRIGHTNESS_SCALE] - device_brightness = min( - round(brightness_normalized * brightness_scale), brightness_scale + device_brightness = color_util.brightness_to_value( + (1, self._config[CONF_BRIGHTNESS_SCALE]), + kwargs[ATTR_BRIGHTNESS], ) # Make sure the brightness is not rounded down to 0 - device_brightness = max(device_brightness, 1) + device_brightness = max(round(device_brightness), 1) message["brightness"] = device_brightness if self._optimistic: diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 2a3cca666ee..b0826384899 100644 --- a/homeassistant/components/mutesync/strings.json +++ b/homeassistant/components/mutesync/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your mutesync device." } } }, diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index a485a58f5a6..9ebd1c36df0 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your myStrom device." } } }, diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e443a398984..83a40d87f76 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -6,6 +6,9 @@ "description": "Set up Nettigo Air Monitor integration.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Nettigo Air Monitor to control." } }, "credentials": { diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 80eb2ded7d0..13e7c9a11a3 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Nanoleaf device." } }, "link": { diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 6b4883b8ce3..9f3b1aeec9e 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Netgear device. For example: '192.168.1.1'." } } }, diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index fdc9f01d343..cde02327712 100644 --- a/homeassistant/components/nfandroidtv/strings.json +++ b/homeassistant/components/nfandroidtv/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } } }, diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index b386e158420..2708dd75ffe 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -59,7 +59,7 @@ class NoboGlobalSelector(SelectEntity): nobo.API.OVERRIDE_MODE_ECO: "eco", } _attr_options = list(_modes.values()) - _attr_current_option: str + _attr_current_option: str | None = None def __init__(self, hub: nobo, override_type) -> None: """Initialize the global override selector.""" @@ -117,7 +117,7 @@ class NoboProfileSelector(SelectEntity): _attr_should_poll = False _profiles: dict[int, str] = {} _attr_options: list[str] = [] - _attr_current_option: str + _attr_current_option: str | None = None def __init__(self, zone_id: str, hub: nobo) -> None: """Initialize the week profile selector.""" diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 110671864e3..7c78bfc44d3 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -6,17 +6,18 @@ from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast +from homeassistant.config import config_per_platform from homeassistant.const import CONF_DESCRIPTION, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform, async_start_setup from homeassistant.util import slugify -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_DATA, @@ -125,7 +126,7 @@ def async_setup_legacy( hass.data[NOTIFY_SERVICES].setdefault(integration_name, []).append( notify_service ) - hass.config.components.add(f"{DOMAIN}.{integration_name}") + hass.config.components.add(f"{integration_name}.{DOMAIN}") async def async_platform_discovered( platform: str, info: DiscoveryInfoType | None @@ -279,8 +280,8 @@ class BaseNotificationService: # Load service descriptions from notify/services.yaml integration = await async_get_integration(hass, DOMAIN) services_yaml = integration.file_path / "services.yaml" - self.services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + self.services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_register_services(self) -> None: diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index eb380cabd04..216b891ac31 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -7,6 +7,9 @@ "port": "[%key:common::config_flow::data::port%]", "token": "[%key:common::config_flow::data::access_token%]", "encrypt_token": "Use an encrypted token for authentication." + }, + "data_description": { + "host": "The hostname or IP address of your Nuki bridge. For example: 192.168.1.25." } }, "reauth_confirm": { diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 2827911a3aa..7347744d56f 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -2,12 +2,15 @@ "config": { "step": { "user": { - "title": "Connect to the NUT server", + "description": "Connect to the NUT server", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your NUT server." } }, "ups": { diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 05194d85a26..4006a145db4 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.5.1"] + "requirements": ["pynws==1.6.0"] } diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 823bc2e1b8d..f21b4b3706d 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Obihai device." } }, "dhcp_confirm": { @@ -14,6 +17,9 @@ "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::obihai::config::step::user::data_description::host%]" } } }, diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index c6dbfe6f9c4..63d9753ee1d 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -10,6 +10,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your printer." } }, "reauth_confirm": { diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 9e4120b68b2..753f244cfe9 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -12,6 +12,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your 1-Wire device." + }, "title": "Set server details" } } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index cabab347264..5a36b89688a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -36,6 +36,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, + "data_description": { + "host": "The hostname or IP address of your ONVIF device." + }, "title": "Configure ONVIF device" }, "configure_profile": { diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index ba4521d4dcf..f19b458cd0f 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your OpenGarage device." } } }, diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index d33dfec6adf..106103cf752 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.1"] + "requirements": ["python-opensky==1.0.0"] } diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 1022ab07e2c..d3a5928150e 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.39"] + "requirements": ["opower==0.0.40"] } diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py new file mode 100644 index 00000000000..f0b89eaea90 --- /dev/null +++ b/homeassistant/components/osoenergy/__init__.py @@ -0,0 +1,81 @@ +"""Support for the OSO Energy devices and services.""" +from typing import Any, Generic, TypeVar + +from aiohttp.web_exceptions import HTTPException +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) +from apyosoenergyapi.helper.osoenergy_exceptions import OSOEnergyReauthRequired + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_T = TypeVar( + "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData +) + +PLATFORMS = [ + Platform.WATER_HEATER, +] +PLATFORM_LOOKUP = { + Platform.WATER_HEATER: "water_heater", +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OSO Energy from a config entry.""" + subscription_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + osoenergy = OSOEnergy(subscription_key, websession) + + osoenergy_config = dict(entry.data) + + hass.data.setdefault(DOMAIN, {}) + + try: + devices: Any = await osoenergy.session.start_session(osoenergy_config) + except HTTPException as error: + raise ConfigEntryNotReady() from error + except OSOEnergyReauthRequired as err: + raise ConfigEntryAuthFailed from err + + hass.data[DOMAIN][entry.entry_id] = osoenergy + + platforms = set() + for ha_type, oso_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(oso_type, []) + if device_list: + platforms.add(ha_type) + if platforms: + await hass.config_entries.async_forward_entry_setups(entry, platforms) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class OSOEnergyEntity(Entity, Generic[_T]): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.device = osoenergy_device + self._attr_unique_id = osoenergy_device.device_id diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py new file mode 100644 index 00000000000..a7632b19bcb --- /dev/null +++ b/homeassistant/components/osoenergy/config_flow.py @@ -0,0 +1,75 @@ +"""Config Flow for OSO Energy.""" +from collections.abc import Mapping +import logging +from typing import Any + +from apyosoenergyapi import OSOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a OSO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize.""" + self.entry: ConfigEntry | None = None + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Verify Subscription key + if user_email := await self.get_user_email(user_input[CONF_API_KEY]): + await self.async_set_unique_id(user_email) + + if ( + self.context["source"] == config_entries.SOURCE_REAUTH + and self.entry + ): + self.hass.config_entries.async_update_entry( + self.entry, title=user_email, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_email, data=user_input) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA_STEP_USER, + errors=errors, + ) + + async def get_user_email(self, subscription_key: str) -> str | None: + """Return the user email for the provided subscription key.""" + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + client = OSOEnergy(subscription_key, websession) + return await client.get_user_email() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred") + return None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Re Authenticate a user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + return await self.async_step_user(data) diff --git a/homeassistant/components/osoenergy/const.py b/homeassistant/components/osoenergy/const.py new file mode 100644 index 00000000000..c3925f5259b --- /dev/null +++ b/homeassistant/components/osoenergy/const.py @@ -0,0 +1,3 @@ +"""Constants for OSO Energy.""" + +DOMAIN = "osoenergy" diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json new file mode 100644 index 00000000000..d6813108242 --- /dev/null +++ b/homeassistant/components/osoenergy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "osoenergy", + "name": "OSO Energy", + "codeowners": ["@osohotwateriot"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/osoenergy", + "iot_class": "cloud_polling", + "requirements": ["pyosoenergyapi==1.1.3"] +} diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json new file mode 100644 index 00000000000..a45482bf030 --- /dev/null +++ b/homeassistant/components/osoenergy/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "OSO Energy Auth", + "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth": { + "title": "OSO Energy Auth", + "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py new file mode 100644 index 00000000000..4b2ad7c48d6 --- /dev/null +++ b/homeassistant/components/osoenergy/water_heater.py @@ -0,0 +1,142 @@ +"""Support for OSO Energy water heaters.""" +from typing import Any + +from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OSOEnergyEntity +from .const import DOMAIN + +CURRENT_OPERATION_MAP: dict[str, Any] = { + "default": { + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, + "oso": { + "auto": STATE_ECO, + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, +} +HEATER_MIN_TEMP = 10 +HEATER_MAX_TEMP = 80 +MANUFACTURER = "OSO Energy" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy heater based on a config entry.""" + osoenergy = hass.data[DOMAIN][entry.entry_id] + devices = osoenergy.session.device_list.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(OSOEnergyWaterHeater(osoenergy, dev)) + async_add_entities(entities, True) + + +class OSOEnergyWaterHeater( + OSOEnergyEntity[OSOEnergyWaterHeaterData], WaterHeaterEntity +): + """OSO Energy Water Heater Device.""" + + _attr_name = None + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.available + + @property + def current_operation(self) -> str: + """Return current operation.""" + status = self.device.current_operation + if status == "off": + return STATE_OFF + + optimization_mode = self.device.optimization_mode.lower() + heater_mode = self.device.heater_mode.lower() + if optimization_mode in CURRENT_OPERATION_MAP: + return CURRENT_OPERATION_MAP[optimization_mode].get( + heater_mode, STATE_ELECTRIC + ) + + return CURRENT_OPERATION_MAP["default"].get(heater_mode, STATE_ELECTRIC) + + @property + def current_temperature(self) -> float: + """Return the current temperature of the heater.""" + return self.device.current_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature + + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_low + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device.max_temperature + + async def async_turn_on(self, **kwargs) -> None: + """Turn on hotwater.""" + await self.osoenergy.hotwater.turn_on(self.device, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off hotwater.""" + await self.osoenergy.hotwater.turn_off(self.device, True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature = int(kwargs.get("temperature", self.target_temperature)) + profile = [target_temperature] * 24 + + await self.osoenergy.hotwater.set_profile(self.device, profile) + + async def async_update(self) -> None: + """Update all Node data from Hive.""" + await self.osoenergy.session.update_data() + self.device = await self.osoenergy.hotwater.get_water_heater(self.device) diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 8115066d0fb..5b8d19e5aa1 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -89,7 +89,7 @@ class OurGroceriesTodoListEntity( if item.summary: api_items = self.coordinator.data[self._list_id]["list"]["items"] category = next( - api_item["categoryId"] + api_item.get("categoryId") for api_item in api_items if api_item["id"] == item.uid ) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ebc3f96a7f5..03a81f67308 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -1,10 +1,8 @@ """The Overkiz (by Somfy) integration.""" from __future__ import annotations -import asyncio from collections import defaultdict from dataclasses import dataclass -from typing import cast from aiohttp import ClientError from pyoverkiz.client import OverkizClient @@ -16,7 +14,7 @@ from pyoverkiz.exceptions import ( NotSuchTokenException, TooManyRequestsException, ) -from pyoverkiz.models import Device, OverkizServer, Scenario, Setup +from pyoverkiz.models import Device, OverkizServer, Scenario from pyoverkiz.utils import generate_local_server from homeassistant.config_entries import ConfigEntry @@ -82,13 +80,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await client.login() + setup = await client.get_setup() - setup, scenarios = await asyncio.gather( - *[ - client.get_setup(), - client.get_scenarios(), - ] - ) + # Local API does expose scenarios, but they are not functional. + # Tracked in https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/21 + if api_type == APIType.CLOUD: + scenarios = await client.get_scenarios() + else: + scenarios = [] except (BadCredentialsException, NotSuchTokenException) as exception: raise ConfigEntryAuthFailed("Invalid authentication") from exception except TooManyRequestsException as exception: @@ -98,9 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except MaintenanceException as exception: raise ConfigEntryNotReady("Server is down for maintenance") from exception - setup = cast(Setup, setup) - scenarios = cast(list[Scenario], scenarios) - coordinator = OverkizDataUpdateCoordinator( hass, LOGGER, diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 41c2f4d1a92..a267b54b398 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -100,7 +100,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Water volume estimation at 40 °C", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.VOLUME_STORAGE, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), @@ -110,7 +110,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.IO_OUTLET_ENGINE, @@ -481,7 +481,12 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): """Return the value of the sensor.""" state = self.device.states.get(self.entity_description.key) - if not state or not state.value: + if ( + state is None + or state.value is None + or self.state_class != SensorStateClass.MEASUREMENT + and not state.value + ): return None # Transform the value with a lambda function diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 2a549f1c24d..a756df4d0d6 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -9,7 +9,7 @@ } }, "local_or_cloud": { - "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices are not supported in local API.", + "description": "Choose between local or cloud API. Local API supports TaHoma Connexoon, TaHoma v2, and TaHoma Switch. Climate devices and scenarios are not supported in local API.", "data": { "api_type": "API type" } diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index ad74200dace..bcdc4195100 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -5,7 +5,14 @@ from dataclasses import dataclass from datetime import timedelta from typing import Final -from peco import AlertResults, BadJSONError, HttpError, OutageResults, PecoOutageApi +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + PecoOutageApi, + UnresponsiveMeterError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -13,9 +20,16 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTY, DOMAIN, LOGGER, SCAN_INTERVAL +from .const import ( + CONF_COUNTY, + CONF_PHONE_NUMBER, + DOMAIN, + LOGGER, + OUTAGE_SCAN_INTERVAL, + SMART_METER_SCAN_INTERVAL, +) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.SENSOR, Platform.BINARY_SENSOR] @dataclass @@ -31,9 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass) api = PecoOutageApi() + # Outage Counter Setup county: str = entry.data[CONF_COUNTY] - async def async_update_data() -> PECOCoordinatorData: + async def async_update_outage_data() -> OutageResults: """Fetch data from API.""" try: outages: OutageResults = ( @@ -53,15 +68,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, LOGGER, name="PECO Outage Count", - update_method=async_update_data, - update_interval=timedelta(minutes=SCAN_INTERVAL), + update_method=async_update_outage_data, + update_interval=timedelta(minutes=OUTAGE_SCAN_INTERVAL), ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"outage_count": coordinator} + if phone_number := entry.data.get(CONF_PHONE_NUMBER): + # Smart Meter Setup] + + async def async_update_meter_data() -> bool: + """Fetch data from API.""" + try: + data: bool = await api.meter_check(phone_number, websession) + except UnresponsiveMeterError as err: + raise UpdateFailed("Unresponsive meter") from err + except HttpError as err: + raise UpdateFailed(f"Error fetching data: {err}") from err + except BadJSONError as err: + raise UpdateFailed(f"Error parsing data: {err}") from err + return data + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="PECO Smart Meter", + update_method=async_update_meter_data, + update_interval=timedelta(minutes=SMART_METER_SCAN_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id]["smart_meter"] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py new file mode 100644 index 00000000000..7f0402b207f --- /dev/null +++ b/homeassistant/components/peco/binary_sensor.py @@ -0,0 +1,59 @@ +"""Binary sensor for PECO outage counter.""" +from __future__ import annotations + +from typing import Final + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +PARALLEL_UPDATES: Final = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor for PECO.""" + if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: + return + coordinator: DataUpdateCoordinator[bool] = hass.data[DOMAIN][config_entry.entry_id][ + "smart_meter" + ] + + async_add_entities( + [PecoBinarySensor(coordinator, phone_number=config_entry.data["phone_number"])] + ) + + +class PecoBinarySensor( + CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity +): + """Binary sensor for PECO outage counter.""" + + _attr_icon = "mdi:gauge" + _attr_device_class = BinarySensorDeviceClass.POWER + _attr_name = "Meter Status" + + def __init__( + self, coordinator: DataUpdateCoordinator[bool], phone_number: str + ) -> None: + """Initialize binary sensor for PECO.""" + super().__init__(coordinator) + self._attr_unique_id = f"{phone_number}" + + @property + def is_on(self) -> bool: + """Return if the meter has power.""" + return self.coordinator.data diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index 63ca7f3291a..144495ec066 100644 --- a/homeassistant/components/peco/config_flow.py +++ b/homeassistant/components/peco/config_flow.py @@ -1,41 +1,108 @@ """Config flow for PECO Outage Counter integration.""" from __future__ import annotations +import logging from typing import Any +from peco import ( + HttpError, + IncompatibleMeterError, + PecoOutageApi, + UnresponsiveMeterError, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv -from .const import CONF_COUNTY, COUNTY_LIST, DOMAIN +from .const import CONF_COUNTY, CONF_PHONE_NUMBER, COUNTY_LIST, DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_COUNTY): vol.In(COUNTY_LIST), + vol.Optional(CONF_PHONE_NUMBER): cv.string, } ) +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PECO Outage Counter.""" VERSION = 1 + meter_data: dict[str, str] = {} + meter_error: dict[str, str] = {} + + async def _verify_meter(self, phone_number: str) -> None: + """Verify if the meter is compatible.""" + + api = PecoOutageApi() + + try: + await api.meter_check(phone_number) + except ValueError: + self.meter_error = {"phone_number": "invalid_phone_number", "type": "error"} + except IncompatibleMeterError: + self.meter_error = {"phone_number": "incompatible_meter", "type": "abort"} + except UnresponsiveMeterError: + self.meter_error = {"phone_number": "unresponsive_meter", "type": "error"} + except HttpError: + self.meter_error = {"phone_number": "http_error", "type": "error"} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, ) county = user_input[CONF_COUNTY] - await self.async_set_unique_id(county) + if CONF_PHONE_NUMBER not in user_input: + await self.async_set_unique_id(county) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{user_input[CONF_COUNTY].capitalize()} Outage Count", + data=user_input, + ) + + phone_number = user_input[CONF_PHONE_NUMBER] + + await self.async_set_unique_id(f"{county}-{phone_number}") self._abort_if_unique_id_configured() + if self.meter_error is not None: + # Clear any previous errors, since the user may have corrected them + self.meter_error = {} + + await self._verify_meter(phone_number) + + self.meter_data = user_input + + return await self.async_step_finish_smart_meter() + + async def async_step_finish_smart_meter( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the finish smart meter step.""" + if "phone_number" in self.meter_error: + if self.meter_error["type"] == "error": + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"phone_number": self.meter_error["phone_number"]}, + ) + + return self.async_abort(reason=self.meter_error["phone_number"]) + return self.async_create_entry( - title=f"{county.capitalize()} Outage Count", data=user_input + title=f"{self.meter_data[CONF_COUNTY].capitalize()} - {self.meter_data[CONF_PHONE_NUMBER]}", + data=self.meter_data, ) diff --git a/homeassistant/components/peco/const.py b/homeassistant/components/peco/const.py index b0198ac8761..1df8ae41ecb 100644 --- a/homeassistant/components/peco/const.py +++ b/homeassistant/components/peco/const.py @@ -14,6 +14,8 @@ COUNTY_LIST: Final = [ "TOTAL", ] CONFIG_FLOW_COUNTIES: Final = [{county: county.capitalize()} for county in COUNTY_LIST] -SCAN_INTERVAL: Final = 9 +OUTAGE_SCAN_INTERVAL: Final = 9 # minutes +SMART_METER_SCAN_INTERVAL: Final = 15 # minutes CONF_COUNTY: Final = "county" ATTR_CONTENT: Final = "content" +CONF_PHONE_NUMBER: Final = "phone_number" diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 5be41f7c7e1..935f2b659f9 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -91,7 +91,7 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["outage_count"] async_add_entities( PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index 059b2ba71a7..cdf5bb497db 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -3,12 +3,26 @@ "step": { "user": { "data": { - "county": "County" + "county": "County", + "phone_number": "Phone Number" + }, + "data_description": { + "county": "County used for outage number retrieval", + "phone_number": "Phone number associated with the PECO account (optional). Adding a phone number adds a binary sensor confirming if your power is out or not, and not an issue with a breaker or an issue on your end." } } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "incompatible_meter": "Your meter is not compatible with smart meter checking." + }, + "progress": { + "verifying_meter": "One moment. Verifying that your meter is compatible. This may take a minute or two." + }, + "error": { + "invalid_phone_number": "Please enter a valid phone number.", + "unresponsive_meter": "Your meter is not responding. Please try again later.", + "http_error": "There was an error communicating with PECO. The issue that is most likely is that you entered an invalid phone number. Please check the phone number or try again later." } }, "entity": { diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 78aa9f17b05..1e6c1241aea 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -66,6 +66,8 @@ async def async_setup_entry( def _check_for_recording_entry(api: PhilipsTV, entry: str, value: str) -> bool: """Return True if at least one specified value is available within entry of list.""" + if api.recordings_list is None: + return False for rec in api.recordings_list["recordings"]: if rec.get(entry) == value: return True diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 47b9685c9ec..fea99f7403d 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -12,6 +12,7 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -31,7 +32,7 @@ async def async_setup_entry( """Set up the Picnic shopping cart todo platform config entry.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] - async_add_entities([PicnicCart(hass, picnic_coordinator, config_entry)]) + async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): @@ -44,7 +45,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): def __init__( self, - hass: HomeAssistant, coordinator: PicnicUpdateCoordinator, config_entry: ConfigEntry, ) -> None: @@ -56,7 +56,6 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): manufacturer="Picnic", model=config_entry.unique_id, ) - self.hass = hass self._attr_unique_id = f"{config_entry.unique_id}-cart" @property @@ -87,7 +86,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): ) if not product_id: - raise ValueError("No product found or no product ID given") + raise ServiceValidationError("No product found or no product ID given") await self.hass.async_add_executor_job( self.coordinator.picnic_api_client.add_product, product_id, 1 diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index e6cad32f3de..97636111586 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -18,11 +18,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DEFAULT_PING_COUNT, DOMAIN from .coordinator import PingUpdateCoordinator -from .entity import BasePingEntity _LOGGER = logging.getLogger(__name__) @@ -84,16 +84,20 @@ async def async_setup_entry( async_add_entities([PingBinarySensor(entry, data.coordinators[entry.entry_id])]) -class PingBinarySensor(BasePingEntity, BinarySensorEntity): +class PingBinarySensor(CoordinatorEntity[PingUpdateCoordinator], BinarySensorEntity): """Representation of a Ping Binary sensor.""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_available = False def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" - super().__init__(config_entry, coordinator) + super().__init__(coordinator) + + self._attr_name = config_entry.title + self._attr_unique_id = config_entry.entry_id # if this was imported just enable it when it was enabled before if CONF_IMPORTED_BY in config_entry.data: @@ -108,7 +112,7 @@ class PingBinarySensor(BasePingEntity, BinarySensorEntity): @property def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the ICMP echo request.""" + """Return the state attributes of the ICMP checo request.""" if self.coordinator.data.data is None: return None return { diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index dadd105b606..f6bda9693b8 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -40,7 +40,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): hass, _LOGGER, name=f"Ping {ping.ip_address}", - update_interval=timedelta(minutes=5), + update_interval=timedelta(seconds=30), ) async def _async_update_data(self) -> PingResult: diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 1bce965ee55..417659aad5c 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -2,32 +2,39 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, AsyncSeeCallback, + ScannerEntity, SourceType, ) -from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + remove_device_from_config, +) +from homeassistant.config import load_yaml_config_file from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_HOSTS, CONF_NAME, - STATE_HOME, - STATE_NOT_HOME, + EVENT_HOMEASSISTANT_STARTED, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN -from .entity import BasePingEntity +from .coordinator import PingUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -47,34 +54,66 @@ async def async_setup_scanner( ) -> bool: """Legacy init: import via config flow.""" - for dev_name, dev_host in config[CONF_HOSTS].items(): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_IMPORTED_BY: "device_tracker", - CONF_NAME: dev_name, - CONF_HOST: dev_host, - CONF_PING_COUNT: config[CONF_PING_COUNT], - }, - ) + async def _run_import(_: Event) -> None: + """Delete devices from known_device.yaml and import them via config flow.""" + _LOGGER.debug( + "Home Assistant successfully started, importing ping device tracker config entries now" ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Ping", - }, - ) + devices: dict[str, dict[str, Any]] = {} + try: + devices = await hass.async_add_executor_job( + load_yaml_config_file, hass.config.path(YAML_DEVICES) + ) + except (FileNotFoundError, HomeAssistantError): + _LOGGER.debug( + "No valid known_devices.yaml found, " + "skip removal of devices from known_devices.yaml" + ) + + for dev_name, dev_host in config[CONF_HOSTS].items(): + if dev_name in devices: + await hass.async_add_executor_job( + remove_device_from_config, hass, dev_name + ) + _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) + + if not hass.states.async_available(f"device_tracker.{dev_name}"): + hass.states.async_remove(f"device_tracker.{dev_name}") + + # run import after everything has been cleaned up + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_IMPORTED_BY: "device_tracker", + CONF_NAME: dev_name, + CONF_HOST: dev_host, + CONF_PING_COUNT: config[CONF_PING_COUNT], + }, + ) + ) + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Ping", + }, + ) + + # delay the import until after Home Assistant has started and everything has been initialized, + # as the legacy device tracker entities will be restored after the legacy device tracker platforms + # have been set up, so we can only remove the entities from the state machine then + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) return True @@ -89,20 +128,37 @@ async def async_setup_entry( async_add_entities([PingDeviceTracker(entry, data.coordinators[entry.entry_id])]) -class PingDeviceTracker(BasePingEntity, BaseTrackerEntity): +class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" + def __init__( + self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + ) -> None: + """Initialize the Ping device tracker.""" + super().__init__(coordinator) + + self._attr_name = config_entry.title + self.config_entry = config_entry + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self.coordinator.data.ip_address + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self.config_entry.entry_id + @property def source_type(self) -> SourceType: """Return the source type which is router.""" return SourceType.ROUTER @property - def state(self) -> str: - """Return the state of the device.""" - if self.coordinator.data.is_alive: - return STATE_HOME - return STATE_NOT_HOME + def is_connected(self) -> bool: + """Return true if ping returns is_alive.""" + return self.coordinator.data.is_alive @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/ping/entity.py b/homeassistant/components/ping/entity.py deleted file mode 100644 index 058d8c967e5..00000000000 --- a/homeassistant/components/ping/entity.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Base entity for Ping integration.""" -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import PingUpdateCoordinator - - -class BasePingEntity(CoordinatorEntity[PingUpdateCoordinator]): - """Representation of a Ping base entity.""" - - _attr_has_entity_name = True - _attr_name = None - - def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator - ) -> None: - """Initialize the Ping Binary sensor.""" - super().__init__(coordinator) - - self._attr_unique_id = config_entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.coordinator.data.ip_address)}, - manufacturer="Ping", - ) - - self.config_entry = config_entry diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 31441df7736..12bc1d25c7a 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "count": "Ping count" + }, + "data_description": { + "host": "The hostname or IP address of the device you want to ping." } } }, diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index a11d2d865c2..6dbd6118d7c 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.4", + "PlexAPI==4.15.6", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index efad1b7466b..84e0619773b 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -160,6 +160,16 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): # Keep track of the previous action-mode self._previous_action_mode(self.coordinator) + # Adam provides the hvac_action for each thermostat + if (control_state := self.device.get("control_state")) == "cooling": + return HVACAction.COOLING + if control_state == "heating": + return HVACAction.HEATING + if control_state == "preheating": + return HVACAction.PREHEATING + if control_state == "off": + return HVACAction.IDLE + heater: str = self.coordinator.data.gateway["heater_id"] heater_data = self.coordinator.data.devices[heater] if heater_data["binary_sensors"]["heating_state"]: diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 1373ba40fa3..bb2b428bf19 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.34.3"], + "requirements": ["plugwise==0.34.5"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 33395f5fe6a..8587101a42a 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -122,9 +122,9 @@ class PowerwallDataManager: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" http_session = requests.Session() - ip_address = entry.data[CONF_IP_ADDRESS] + ip_address: str = entry.data[CONF_IP_ADDRESS] - password = entry.data.get(CONF_PASSWORD) + password: str | None = entry.data.get(CONF_PASSWORD) power_wall = Powerwall(ip_address, http_session=http_session) try: base_info = await hass.async_add_executor_job( @@ -184,7 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str + power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" if password is not None: diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index b18716d8020..ed7fc975d9e 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.15.0"] + "requirements": ["bluetooth-data-tools==1.17.0"] } diff --git a/homeassistant/components/progettihwsw/strings.json b/homeassistant/components/progettihwsw/strings.json index bb98d565594..d50c6f8d4e3 100644 --- a/homeassistant/components/progettihwsw/strings.json +++ b/homeassistant/components/progettihwsw/strings.json @@ -13,6 +13,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of your ProgettiHWSW board." } }, "relay_modes": { diff --git a/homeassistant/components/pushbullet/api.py b/homeassistant/components/pushbullet/api.py index ff6a57aa931..691ef7413c3 100644 --- a/homeassistant/components/pushbullet/api.py +++ b/homeassistant/components/pushbullet/api.py @@ -1,4 +1,5 @@ """Pushbullet Notification provider.""" +from __future__ import annotations from typing import Any @@ -10,7 +11,7 @@ from homeassistant.helpers.dispatcher import dispatcher_send from .const import DATA_UPDATED -class PushBulletNotificationProvider(Listener): +class PushBulletNotificationProvider(Listener): # type: ignore[misc] """Provider for an account, leading to one or more sensors.""" def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 1cc851bdb99..662240d0bf5 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .api import PushBulletNotificationProvider from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -34,8 +35,10 @@ async def async_get_service( """Get the Pushbullet notification service.""" if TYPE_CHECKING: assert discovery_info is not None - pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet - return PushBulletNotificationService(hass, pushbullet) + pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][ + discovery_info["entry_id"] + ] + return PushBulletNotificationService(hass, pb_provider.pushbullet) class PushBulletNotificationService(BaseNotificationService): @@ -120,7 +123,7 @@ class PushBulletNotificationService(BaseNotificationService): pusher: PushBullet, email: str | None = None, phonenumber: str | None = None, - ): + ) -> None: """Create the message content.""" kwargs = {"body": message, "title": title} if email: diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 10751d28c06..098603b9494 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import raise_if_invalid_filename import homeassistant.util.dt as dt_util -from homeassistant.util.yaml.loader import load_yaml +from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ def discover_scripts(hass): # Load user-provided service descriptions from python_scripts/services.yaml services_yaml = os.path.join(path, "services.yaml") if os.path.exists(services_yaml): - services_dict = load_yaml(services_yaml) + services_dict = load_yaml_dict(services_yaml) else: services_dict = {} diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index a5fa3c8a897..d535b9f0e87 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "The hostname or IP address of your QNAP device." } } }, diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 39258e2f787..b6b05b5b568 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, @@ -31,7 +32,7 @@ from .coordinator import ( T, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -46,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { + "calendar": CalendarUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py new file mode 100644 index 00000000000..3a5308fffd5 --- /dev/null +++ b/homeassistant/components/radarr/calendar.py @@ -0,0 +1,63 @@ +"""Support for Radarr calendar items.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RadarrEntity +from .const import DOMAIN +from .coordinator import CalendarUpdateCoordinator, RadarrEvent + +CALENDAR_TYPE = EntityDescription( + key="calendar", + name=None, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Radarr calendar entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) + + +class RadarrCalendarEntity(RadarrEntity, CalendarEntity): + """A Radarr calendar entity.""" + + coordinator: CalendarUpdateCoordinator + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.event: + return None + return CalendarEvent( + summary=self.coordinator.event.summary, + start=self.coordinator.event.start, + end=self.coordinator.event.end, + description=self.coordinator.event.description, + ) + + # pylint: disable-next=hass-return-type + async def async_get_events( # type: ignore[override] + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get all events in a specific time frame.""" + return await self.coordinator.async_get_events(start_date, end_date) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self.coordinator.event: + self._attr_extra_state_attributes = { + "release_type": self.coordinator.event.release_type + } + else: + self._attr_extra_state_attributes = {} + super().async_write_ha_state() diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index bd41810bfb8..c14603fe9ca 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -2,13 +2,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import timedelta +import asyncio +from dataclasses import dataclass +from datetime import date, datetime, timedelta from typing import Generic, TypeVar, cast -from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions +from aiopyarr import ( + Health, + RadarrCalendarItem, + RadarrMovie, + RootFolder, + SystemStatus, + exceptions, +) from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient +from homeassistant.components.calendar import CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,13 +26,26 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) +T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) + + +@dataclass +class RadarrEventMixIn: + """Mixin for Radarr calendar event.""" + + release_type: str + + +@dataclass +class RadarrEvent(CalendarEvent, RadarrEventMixIn): + """A class to describe a Radarr calendar event.""" class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry + update_interval = timedelta(seconds=30) def __init__( self, @@ -35,7 +58,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=self.update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -101,3 +124,77 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): return ( await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) ).totalRecords + + +class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): + """Calendar update coordinator.""" + + update_interval = timedelta(hours=1) + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize.""" + super().__init__(hass, host_configuration, api_client) + self.event: RadarrEvent | None = None + self._events: list[RadarrEvent] = [] + + async def _fetch_data(self) -> None: + """Fetch the calendar.""" + self.event = None + _date = datetime.today() + while self.event is None: + await self.async_get_events(_date, _date + timedelta(days=1)) + for event in self._events: + if event.start >= _date.date(): + self.event = event + break + # Prevent infinite loop in case there is nothing recent in the calendar + if (_date - datetime.today()).days > 45: + break + _date = _date + timedelta(days=1) + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get cached events and request missing dates.""" + # remove older events to prevent memory leak + self._events = [ + e + for e in self._events + if e.start >= datetime.now().date() - timedelta(days=30) + ] + _days = (end_date - start_date).days + await asyncio.gather( + *( + self._async_get_events(d) + for d in ((start_date + timedelta(days=x)).date() for x in range(_days)) + if d not in (event.start for event in self._events) + ) + ) + return self._events + + async def _async_get_events(self, _date: date) -> None: + """Return events from specified date.""" + self._events.extend( + _get_calendar_event(evt) + for evt in await self.api_client.async_get_calendar( + start_date=_date, end_date=_date + timedelta(days=1) + ) + if evt.title not in (e.summary for e in self._events) + ) + + +def _get_calendar_event(event: RadarrCalendarItem) -> RadarrEvent: + """Return a RadarrEvent from an API event.""" + _date, _type = event.releaseDateType() + return RadarrEvent( + summary=event.title, + start=_date - timedelta(days=1), + end=_date, + description=event.overview.replace(":", ";"), + release_type=_type, + ) diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 693811f59ab..e76bd2d3f2d 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Radio Thermostat." } }, "confirm": { diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index c149c993acb..e5731dc08fe 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -183,7 +183,7 @@ def _async_fix_device_id( device_entry_map = {} migrations = {} for device_entry in device_entries: - unique_id = next(iter(device_entry.identifiers))[1] + unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 6046189ddc4..ea0d64f6208 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Rain Bird device." } } }, diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index 58c7f6bd795..7b5054bfb0f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "cloud_id": "Cloud ID", "install_code": "Installation Code" + }, + "data_description": { + "host": "The hostname or IP address of your Rainforest gateway." } } }, diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index c29154a941c..fde9b945e53 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta from functools import partial, wraps @@ -326,10 +326,17 @@ async def async_setup_entry( # noqa: C901 entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - def call_with_controller(update_programs_and_zones: bool = True) -> Callable: + def call_with_controller( + update_programs_and_zones: bool = True, + ) -> Callable[ + [Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]]], + Callable[[ServiceCall], Coroutine[Any, Any, None]], + ]: """Hydrate a service call with the appropriate controller.""" - def decorator(func: Callable) -> Callable[..., Awaitable]: + def decorator( + func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define the decorator.""" @wraps(func) diff --git a/homeassistant/components/remote/significant_change.py b/homeassistant/components/remote/significant_change.py new file mode 100644 index 00000000000..8e5a3669041 --- /dev/null +++ b/homeassistant/components/remote/significant_change.py @@ -0,0 +1,27 @@ +"""Helper to test significant Remote state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CURRENT_ACTIVITY + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + if old_attrs.get(ATTR_CURRENT_ACTIVITY) != new_attrs.get(ATTR_CURRENT_ACTIVITY): + return True + + return False diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py index 840e1ce428a..53bbd90c4b7 100644 --- a/homeassistant/components/renson/const.py +++ b/homeassistant/components/renson/const.py @@ -1,3 +1,4 @@ """Constants for the Renson integration.""" + DOMAIN = "renson" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index da6850859a6..a60adccade5 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -7,16 +7,19 @@ from typing import Any from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType from renson_endura_delta.renson import Level, RensonVentilation +import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN from .coordinator import RensonCoordinator @@ -41,6 +44,33 @@ SPEED_MAPPING = { Level.LEVEL4.value: 4, } +SET_TIMER_LEVEL_SCHEMA = { + vol.Required("timer_level"): vol.In( + ["level1", "level2", "level3", "level4", "holiday", "breeze"] + ), + vol.Required("minutes"): cv.positive_int, +} + +SET_BREEZE_SCHEMA = { + vol.Required("breeze_level"): vol.In(["level1", "level2", "level3", "level4"]), + vol.Required("temperature"): cv.positive_int, + vol.Required("activate"): bool, +} + +SET_POLLUTION_SETTINGS_SCHEMA = { + vol.Required("day_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Required("night_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Optional("humidity_control", default=True): bool, + vol.Optional("airquality_control", default=True): bool, + vol.Optional("co2_control", default=True): bool, + vol.Optional("co2_threshold", default=600): cv.positive_int, + vol.Optional("co2_hysteresis", default=100): cv.positive_int, +} + SPEED_RANGE: tuple[float, float] = (1, 4) @@ -59,6 +89,24 @@ async def async_setup_entry( async_add_entities([RensonFan(api, coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "set_timer_level", + SET_TIMER_LEVEL_SCHEMA, + "set_timer_level", + ) + + platform.async_register_entity_service( + "set_breeze", SET_BREEZE_SCHEMA, "set_breeze" + ) + + platform.async_register_entity_service( + "set_pollution_settings", + SET_POLLUTION_SETTINGS_SCHEMA, + "set_pollution_settings", + ) + class RensonFan(RensonEntity, FanEntity): """Representation of the Renson fan platform.""" @@ -116,3 +164,43 @@ class RensonFan(RensonEntity, FanEntity): await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) await self.coordinator.async_request_refresh() + + async def set_timer_level(self, timer_level: str, minutes: int) -> None: + """Set timer level.""" + level = Level[str(timer_level).upper()] + + await self.hass.async_add_executor_job(self.api.set_timer_level, level, minutes) + + async def set_breeze( + self, breeze_level: str, temperature: int, activate: bool + ) -> None: + """Configure breeze feature.""" + level = Level[str(breeze_level).upper()] + + await self.hass.async_add_executor_job( + self.api.set_breeze, level, temperature, activate + ) + + async def set_pollution_settings( + self, + day_pollution_level: str, + night_pollution_level: str, + humidity_control: bool, + airquality_control: bool, + co2_control: str, + co2_threshold: int, + co2_hysteresis: int, + ) -> None: + """Configure pollutions settings.""" + day = Level[str(day_pollution_level).upper()] + night = Level[str(night_pollution_level).upper()] + + await self.api.set_pollution( + day, + night, + humidity_control, + airquality_control, + co2_control, + co2_threshold, + co2_hysteresis, + ) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 1a7f367a946..fa94207748e 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.6.0"] + "requirements": ["renson-endura-delta==1.7.1"] } diff --git a/homeassistant/components/renson/services.yaml b/homeassistant/components/renson/services.yaml new file mode 100644 index 00000000000..ad79af8649e --- /dev/null +++ b/homeassistant/components/renson/services.yaml @@ -0,0 +1,117 @@ +set_timer_level: + target: + entity: + integration: renson + domain: fan + fields: + timer_level: + required: true + default: "level1" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + - "holiday" + - "breeze" + minutes: + required: true + default: 0 + selector: + number: + min: 0 + max: 1440 + step: 10 + unit_of_measurement: "min" + mode: slider + +set_breeze: + target: + entity: + integration: renson + domain: fan + fields: + breeze_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + temperature: + default: 18 + selector: + number: + min: 15 + max: 35 + step: 1 + unit_of_measurement: "°C" + mode: slider + activate: + required: true + default: false + selector: + boolean: + +set_pollution_settings: + target: + entity: + integration: renson + domain: fan + fields: + day_pollution_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + night_pollution_level: + default: "level2" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + humidity_control: + default: true + selector: + boolean: + airquality_control: + default: true + selector: + boolean: + co2_control: + default: true + selector: + boolean: + co2_threshold: + default: 600 + selector: + number: + min: 400 + max: 2000 + step: 50 + unit_of_measurement: "ppm" + mode: slider + co2_hysteresis: + default: 100 + selector: + number: + min: 50 + max: 400 + step: 50 + unit_of_measurement: "ppm" + mode: slider diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index d6d03ed1c44..a826b5a3dd3 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Renson Endura delta device." } } }, @@ -159,5 +162,86 @@ "name": "Bypass level" } } + }, + "selector": { + "level_setting": { + "options": { + "off": "[%key:common::state::off%]", + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]", + "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]", + "holiday": "[%key:component::renson::entity::sensor::ventilation_level::state::holiday%]" + } + } + }, + "services": { + "set_timer_level": { + "name": "Set timer", + "description": "Set the ventilation timer", + "fields": { + "timer_level": { + "name": "Level", + "description": "Level setting" + }, + "minutes": { + "name": "Time", + "description": "Time of the timer (0 will disable the timer)" + } + } + }, + "set_breeze": { + "name": "Set breeze", + "description": "Set the breeze function of the ventilation system", + "fields": { + "breeze_level": { + "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", + "description": "Ventilation level when breeze function is activated" + }, + "temperature": { + "name": "Temperature", + "description": "Temperature when the breeze function should be activated" + }, + "activate": { + "name": "Activate", + "description": "Activate or disable the breeze feature" + } + } + }, + "set_pollution_settings": { + "name": "Set pollution settings", + "description": "Set all the pollution settings of the ventilation system", + "fields": { + "day_pollution_level": { + "name": "Day pollution Level", + "description": "Ventilation level when pollution is detected in the day" + }, + "night_pollution_level": { + "name": "Night pollution Level", + "description": "Ventilation level when pollution is detected in the night" + }, + "humidity_control": { + "name": "Enable humidity control", + "description": "Activate or disable the humidity control" + }, + "airquality_control": { + "name": "Enable air quality control", + "description": "Activate or disable the air quality control" + }, + "co2_control": { + "name": "Enable CO2 control", + "description": "Activate or disable the CO2 control" + }, + "co2_threshold": { + "name": "CO2 threshold", + "description": "Sets the CO2 pollution threshold level in ppm" + }, + "co2_hysteresis": { + "name": "CO2 hysteresis", + "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + } + } + } } } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 46761beae00..7f8448d277d 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -148,6 +149,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b firmware_coordinator=firmware_coordinator, ) + cleanup_disconnected_cams(hass, config_entry.entry_id, host) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -175,3 +178,51 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +def cleanup_disconnected_cams( + hass: HomeAssistant, config_entry_id: str, host: ReolinkHost +) -> None: + """Clean-up disconnected camera channels.""" + if not host.api.is_nvr: + return + + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + for device in devices: + device_id = [ + dev_id[1].split("_ch") + for dev_id in device.identifiers + if dev_id[0] == DOMAIN + ][0] + + if len(device_id) < 2: + # Do not consider the NVR itself + continue + + ch = int(device_id[1]) + ch_model = host.api.camera_model(ch) + remove = False + if ch not in host.api.channels: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, " + "since no camera is connected to NVR channel %s anymore", + device.name, + ch, + ) + if ch_model not in [device.model, "Unknown"]: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, " + "since the camera model connected to channel %s changed from %s to %s", + device.name, + ch, + device.model, + ch_model, + ) + if not remove: + continue + + # clean device registry and associated entities + device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index e2e8e6b24f9..226b81b1c74 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,16 +25,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkBinarySensorEntityDescription(BinarySensorEntityDescription): +class ReolinkBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes binary sensor entities.""" icon_off: str = "mdi:motion-sensor-off" icon: str = "mdi:motion-sensor" - supported: Callable[[Host, int], bool] = lambda host, ch: True value: Callable[[Host, int], bool] @@ -128,8 +130,8 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt entity_description: ReolinkBinarySensorEntityDescription, ) -> None: """Initialize Reolink binary sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: if entity_description.translation_key is not None: @@ -138,10 +140,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt key = entity_description.key self._attr_translation_key = f"{key}_lens_{self._channel}" - self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" - ) - @property def icon(self) -> str | None: """Icon of the sensor.""" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6e9c9c2e386..88204d9a806 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -27,7 +27,12 @@ from homeassistant.helpers.entity_platform import ( from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM @@ -36,21 +41,23 @@ SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM @dataclass(kw_only=True) class ReolinkButtonEntityDescription( ButtonEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes button entities for a camera channel.""" enabled_default: Callable[[Host, int], bool] | None = None method: Callable[[Host, int], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True ptz_cmd: str | None = None @dataclass(kw_only=True) -class ReolinkHostButtonEntityDescription(ButtonEntityDescription): +class ReolinkHostButtonEntityDescription( + ButtonEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes button entities for the host.""" method: Callable[[Host], Any] - supported: Callable[[Host], bool] = lambda api: True BUTTON_ENTITIES = ( @@ -195,12 +202,9 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): entity_description: ReolinkButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) if entity_description.enabled_default is not None: self._attr_entity_registry_enabled_default = ( entity_description.enabled_default(self._host.api, self._channel) @@ -241,10 +245,8 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): entity_description: ReolinkHostButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) async def async_press(self) -> None: """Execute the button action.""" diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index ea9b84cd53f..2ad8105c66c 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,11 +1,10 @@ """Component providing support for Reolink IP cameras.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass import logging -from reolink_aio.api import DUAL_LENS_MODELS, Host +from reolink_aio.api import DUAL_LENS_MODELS from reolink_aio.exceptions import ReolinkError from homeassistant.components.camera import ( @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) @@ -28,11 +27,11 @@ _LOGGER = logging.getLogger(__name__) @dataclass(kw_only=True) class ReolinkCameraEntityDescription( CameraEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes camera entities for a camera channel.""" stream: str - supported: Callable[[Host, int], bool] = lambda api, ch: True CAMERA_ENTITIES = ( @@ -135,10 +134,6 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): f"{entity_description.translation_key}_lens_{self._channel}" ) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - async def stream_source(self) -> str | None: """Return the source of the stream.""" return await self._host.api.get_stream_source( diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 5c874fb7ff9..8da64991c27 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,11 +1,14 @@ """Reolink parent entity class.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import TypeVar -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_MODELS, Host from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -17,8 +20,24 @@ from .const import DOMAIN _T = TypeVar("_T") +@dataclass(kw_only=True) +class ReolinkChannelEntityDescription(EntityDescription): + """A class that describes entities for a camera channel.""" + + cmd_key: str | None = None + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass(kw_only=True) +class ReolinkHostEntityDescription(EntityDescription): + """A class that describes host entities.""" + + cmd_key: str | None = None + supported: Callable[[Host], bool] = lambda api: True + + class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): - """Parent class fo Reolink entities.""" + """Parent class for Reolink entities.""" _attr_has_entity_name = True @@ -59,14 +78,29 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): basically a NVR with a single channel that has the camera connected to that channel. """ + entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription + def __init__(self, reolink_data: ReolinkData) -> None: """Initialize ReolinkHostCoordinatorEntity.""" super().__init__(reolink_data, reolink_data.device_coordinator) + self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + if ( + self.entity_description.cmd_key is not None + and self.entity_description.cmd_key not in self._host.update_cmd_list + ): + self._host.update_cmd_list.append(self.entity_description.cmd_key) + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + entity_description: ReolinkChannelEntityDescription + def __init__( self, reolink_data: ReolinkData, @@ -76,6 +110,9 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data) self._channel = channel + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{self.entity_description.key}" + ) dev_ch = channel if self._host.api.model in DUAL_LENS_MODELS: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index f6eb4cb0e55..dfc77806932 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -60,6 +60,8 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) + self.update_cmd_list: list[str] = [] + self.webhook_id: str | None = None self._onvif_push_supported: bool = True self._onvif_long_poll_supported: bool = True @@ -163,7 +165,7 @@ class ReolinkHost: if self._onvif_push_supported: try: await self.subscribe() - except NotSupportedError: + except ReolinkError: self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() @@ -311,7 +313,7 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states() + await self._api.get_states(cmd_list=self.update_cmd_list) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index f1aa0cb9ee2..8df69b156ad 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -22,26 +22,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkLightEntityDescription(LightEntityDescription): +class ReolinkLightEntityDescription( + LightEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes light entities.""" get_brightness_fn: Callable[[Host, int], int | None] | None = None is_on_fn: Callable[[Host, int], bool] set_brightness_fn: Callable[[Host, int, int], Any] | None = None - supported_fn: Callable[[Host, int], bool] = lambda api, ch: True turn_on_off_fn: Callable[[Host, int, bool], Any] LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", + cmd_key="GetWhiteLed", translation_key="floodlight", icon="mdi:spotlight-beam", - supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), @@ -49,19 +52,21 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="ir_lights", + cmd_key="GetIrLights", translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), + supported=lambda api, ch: api.supported(ch, "ir_lights"), is_on_fn=lambda api, ch: api.ir_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), ), ReolinkLightEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "power_led"), + supported=lambda api, ch: api.supported(ch, "power_led"), is_on_fn=lambda api, ch: api.status_led_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), ), @@ -80,7 +85,7 @@ async def async_setup_entry( ReolinkLightEntity(reolink_data, channel, entity_description) for entity_description in LIGHT_ENTITIES for channel in reolink_data.host.api.channels - if entity_description.supported_fn(reolink_data.host.api, channel) + if entity_description.supported(reolink_data.host.api, channel) ) @@ -96,12 +101,8 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): entity_description: ReolinkLightEntityDescription, ) -> None: """Initialize Reolink light entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) if entity_description.set_brightness_fn is None: self._attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5ffbc2fb186..e03fa28b7ce 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.1"] + "requirements": ["reolink-aio==0.8.2"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 1780465850a..aaf549453ed 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -21,24 +21,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass(kw_only=True) -class ReolinkNumberEntityDescription(NumberEntityDescription): +class ReolinkNumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes number entities.""" get_max_value: Callable[[Host, int], float] | None = None get_min_value: Callable[[Host, int], float] | None = None method: Callable[[Host, int, float], Any] mode: NumberMode = NumberMode.AUTO - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], float | None] NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", + cmd_key="GetZoomFocus", translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, @@ -51,6 +54,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="focus", + cmd_key="GetZoomFocus", translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, @@ -66,6 +70,7 @@ NUMBER_ENTITIES = ( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", + cmd_key="GetWhiteLed", translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -78,6 +83,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="volume", + cmd_key="GetAudioCfg", translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -90,6 +96,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="guard_return_time", + cmd_key="GetPtzGuard", translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -103,6 +110,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="motion_sensitivity", + cmd_key="GetMdAlarm", translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, @@ -115,6 +123,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -129,6 +138,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -143,6 +153,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -157,6 +168,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -173,6 +185,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_animal_sensititvity", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -187,6 +200,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_delay", + cmd_key="GetAiAlarm", translation_key="ai_face_delay", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -203,6 +217,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_delay", + cmd_key="GetAiAlarm", translation_key="ai_person_delay", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -219,6 +234,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_delay", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -235,6 +251,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_pet_delay", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -253,6 +270,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_animal_delay", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -269,6 +287,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -282,6 +301,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -295,6 +315,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -308,6 +329,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", + cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -323,6 +345,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", + cmd_key="GetAiCfg", translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -336,6 +359,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="day_night_switch_threshold", + cmd_key="GetIsp", translation_key="day_night_switch_threshold", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -378,8 +402,8 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): entity_description: ReolinkNumberEntityDescription, ) -> None: """Initialize Reolink number entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if entity_description.get_min_value is not None: self._attr_native_min_value = entity_description.get_min_value( @@ -390,9 +414,6 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self._host.api, channel ) self._attr_mode = entity_description.mode - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) @property def native_value(self) -> float | None: diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 566dbc92fbe..eb2ea58cc40 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -24,24 +24,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) @dataclass(kw_only=True) -class ReolinkSelectEntityDescription(SelectEntityDescription): +class ReolinkSelectEntityDescription( + SelectEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes select entities.""" get_options: list[str] | Callable[[Host, int], list[str]] method: Callable[[Host, int, str], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], str] | None = None SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", + cmd_key="GetWhiteLed", translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -52,6 +55,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="day_night_mode", + cmd_key="GetIsp", translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -70,6 +74,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -82,6 +87,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_track_method", + cmd_key="GetAiCfg", translation_key="auto_track_method", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -92,6 +98,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, @@ -131,14 +138,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): entity_description: ReolinkSelectEntityDescription, ) -> None: """Initialize Reolink select entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) self._log_error = True - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - if callable(entity_description.get_options): self._attr_options = entity_description.get_options(self._host.api, channel) else: diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 9a03f497944..5eef880fc91 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -21,36 +21,38 @@ from homeassistant.helpers.typing import StateType from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) @dataclass(kw_only=True) -class ReolinkSensorEntityDescription(SensorEntityDescription): +class ReolinkSensorEntityDescription( + SensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes sensor entities for a camera channel.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], int] -@dataclass -class ReolinkHostSensorEntityDescriptionMixin: - """Mixin values for Reolink host sensor entities.""" - - value: Callable[[Host], int | None] - - -@dataclass +@dataclass(kw_only=True) class ReolinkHostSensorEntityDescription( - SensorEntityDescription, ReolinkHostSensorEntityDescriptionMixin + SensorEntityDescription, + ReolinkHostEntityDescription, ): """A class that describes host sensor entities.""" - supported: Callable[[Host], bool] = lambda api: True + value: Callable[[Host], int | None] SENSORS = ( ReolinkSensorEntityDescription( key="ptz_pan_position", + cmd_key="GetPtzCurPos", translation_key="ptz_pan_position", icon="mdi:pan", state_class=SensorStateClass.MEASUREMENT, @@ -63,6 +65,7 @@ SENSORS = ( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", + cmd_key="GetWifiSignal", translation_key="wifi_signal", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, @@ -110,12 +113,8 @@ class ReolinkSensorEntity(ReolinkChannelCoordinatorEntity, SensorEntity): entity_description: ReolinkSensorEntityDescription, ) -> None: """Initialize Reolink sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def native_value(self) -> StateType | date | datetime | Decimal: @@ -134,10 +133,8 @@ class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity): entity_description: ReolinkHostSensorEntityDescription, ) -> None: """Initialize Reolink host sensor.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) @property def native_value(self) -> StateType | date | datetime | Decimal: diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index f063b65e2b4..ec709f6ae3d 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -1,11 +1,9 @@ """Component providing support for Reolink siren entities.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import Host from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.siren import ( @@ -22,15 +20,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription @dataclass -class ReolinkSirenEntityDescription(SirenEntityDescription): +class ReolinkSirenEntityDescription( + SirenEntityDescription, ReolinkChannelEntityDescription +): """A class that describes siren entities.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True - SIREN_ENTITIES = ( ReolinkSirenEntityDescription( @@ -76,12 +74,8 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): entity_description: ReolinkSirenEntityDescription, ) -> None: """Initialize Reolink siren entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the siren.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5b26d70b657..04b46323e11 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -10,6 +10,13 @@ "use_https": "Enable HTTPS", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", + "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", + "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } }, "reauth_confirm": { @@ -35,6 +42,9 @@ "init": { "data": { "protocol": "Protocol" + }, + "data_description": { + "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index eb77b16478f..352ba7a1103 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -17,30 +17,40 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) @dataclass(kw_only=True) -class ReolinkSwitchEntityDescription(SwitchEntityDescription): +class ReolinkSwitchEntityDescription( + SwitchEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes switch entities.""" method: Callable[[Host, int, bool], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], bool] @dataclass(kw_only=True) -class ReolinkNVRSwitchEntityDescription(SwitchEntityDescription): +class ReolinkNVRSwitchEntityDescription( + SwitchEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes NVR switch entities.""" method: Callable[[Host, bool], Any] - supported: Callable[[Host], bool] = lambda api: True value: Callable[[Host], bool] SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="record_audio", + cmd_key="GetEnc", translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, @@ -50,6 +60,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="siren_on_event", + cmd_key="GetAudioAlarm", translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, @@ -59,6 +70,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_tracking", + cmd_key="GetAiCfg", translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -68,6 +80,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_focus", + cmd_key="GetAutoFocus", translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, @@ -77,6 +90,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="gaurd_return", + cmd_key="GetPtzGuard", translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -86,6 +100,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -95,6 +110,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -104,6 +120,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -113,6 +130,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -122,6 +140,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -131,6 +150,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", + cmd_key="GetAudioCfg", translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -140,6 +160,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="hdr", + cmd_key="GetIsp", translation_key="hdr", icon="mdi:hdr", entity_category=EntityCategory.CONFIG, @@ -153,6 +174,7 @@ SWITCH_ENTITIES = ( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -162,6 +184,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -171,6 +194,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -180,6 +204,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -189,6 +214,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -235,12 +261,8 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): entity_description: ReolinkSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def is_on(self) -> bool: @@ -275,8 +297,8 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): entity_description: ReolinkNVRSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data) self.entity_description = entity_description + super().__init__(reolink_data) self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index a75af46e81e..ffd429e92ad 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -1,6 +1,7 @@ """Update entities for Reolink devices.""" from __future__ import annotations +from datetime import datetime import logging from typing import Any, Literal @@ -13,9 +14,10 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later from . import ReolinkData from .const import DOMAIN @@ -23,6 +25,8 @@ from .entity import ReolinkBaseCoordinatorEntity LOGGER = logging.getLogger(__name__) +POLL_AFTER_INSTALL = 120 + async def async_setup_entry( hass: HomeAssistant, @@ -51,6 +55,7 @@ class ReolinkUpdateEntity( super().__init__(reolink_data, reolink_data.firmware_coordinator) self._attr_unique_id = f"{self._host.unique_id}" + self._cancel_update: CALLBACK_TYPE | None = None @property def installed_version(self) -> str | None: @@ -100,3 +105,16 @@ class ReolinkUpdateEntity( ) from err finally: self.async_write_ha_state() + self._cancel_update = async_call_later( + self.hass, POLL_AFTER_INSTALL, self._async_update_future + ) + + async def _async_update_future(self, now: datetime | None = None) -> None: + """Request update.""" + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + await super().async_will_remove_from_hass() + if self._cancel_update is not None: + self._cancel_update() diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 102bb024924..f80143e2f9e 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -171,7 +171,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = True else: _LOGGER.error( @@ -186,7 +186,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = False else: _LOGGER.error( diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 54a60d34229..12b9290af99 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -643,7 +643,7 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b else: try: conn = rfxtrxmod.PySerialTransport(device) - except serial.serialutil.SerialException: + except serial.SerialException: return False if conn.serial is None: diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 85ddf559cf5..9b99553d3f0 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -19,6 +19,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your RFXCOM RFXtrx device." + }, "title": "Select connection address" }, "setup_serial": { diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 4173d2f5c6e..acaf2e5adbc 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -39,7 +39,7 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.CHANNELS, ] -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] +GetBrowseImageUrlType = Callable[[str, str, "str | None"], str | None] def get_thumbnail_url_full( diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index f1816d58613..654c1b7fdfc 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -7,6 +7,9 @@ "description": "Select a Roomba or Braava.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "manual": { @@ -14,6 +17,9 @@ "description": "No Roomba or Braava have been discovered on your network.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Roomba or Braava." } }, "link": { diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index d6128d26723..dda323c2c2a 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -373,14 +373,14 @@ class RoonDevice(MediaPlayerEntity): def volume_up(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, 1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, 1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, 3) def volume_down(self) -> None: """Send new volume_level to device.""" if self._volume_incremental: - self._server.roonapi.change_volume_raw(self.output_id, -1, "relative_step") + self._server.roonapi.change_volume_raw(self.output_id, -1, "relative") else: self._server.roonapi.change_volume_percent(self.output_id, -3) diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index 769cde67d7a..65a39e5e218 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -6,6 +6,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Ruckus access point." } } }, diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 47a9bbfdde0..8a154bca019 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -10,7 +10,7 @@ from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, - BaseHaRemoteScanner, + HomeAssistantRemoteScanner, async_get_advertisement_callback, async_register_scanner, ) @@ -22,7 +22,7 @@ from .coordinator import RuuviGatewayUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class RuuviGatewayScanner(BaseHaRemoteScanner): +class RuuviGatewayScanner(HomeAssistantRemoteScanner): """Scanner for Ruuvi Gateway.""" def __init__( diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index babdbc573bd..b1d118e6f75 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,8 +1,9 @@ """Support for monitoring an SABnzbd NZB client.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging +from typing import Any from pysabnzbd import SabnzbdApiException import voluptuous as vol @@ -189,7 +190,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_device_identifiers(hass, entry) @callback - def extract_api(func: Callable) -> Callable: + def extract_api( + func: Callable[[ServiceCall, SabnzbdApiData], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct api for a service call.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb..c9d08f756d0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "The hostname or IP address of your TV." } }, "confirm": { diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 1eb7cb2ab0f..e14a5bc706e 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.11.0"] + "requirements": ["pyschlage==2023.12.0"] } diff --git a/homeassistant/components/scl/__init__.py b/homeassistant/components/scl/__init__.py new file mode 100644 index 00000000000..ae3b8d58f5e --- /dev/null +++ b/homeassistant/components/scl/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Seattle City Light (SCL).""" diff --git a/homeassistant/components/scl/manifest.json b/homeassistant/components/scl/manifest.json new file mode 100644 index 00000000000..11fce2c4b47 --- /dev/null +++ b/homeassistant/components/scl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "scl", + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1d3f366a498..d78c2c16e48 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const.common import UNIT +from screenlogicpy.const.common import UNIT, ScreenLogicCommunicationError from screenlogicpy.const.data import ATTR, DEVICE, VALUE from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.heat import HEAT_MODE @@ -150,13 +150,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - if not await self.gateway.async_set_heat_temp( - int(self._data_key), int(temperature) - ): + try: + await self.gateway.async_set_heat_temp( + int(self._data_key), int(temperature) + ) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -166,13 +169,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): else: mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_hvac_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -183,13 +187,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_preset_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 74f49927171..f16f2b9ff34 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -2,8 +2,13 @@ from datetime import timedelta import logging -from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.common import ( + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, + ScreenLogicCommunicationError, +) from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry @@ -91,7 +96,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - except ScreenLogicError as ex: + except ScreenLogicCommunicationError as sle: if self.gateway.is_connected: await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + raise UpdateFailed(sle.msg) from sle diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 719cebc1ef6..cda1bc83f81 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -8,7 +8,10 @@ ENTITY_MIGRATIONS = { "new_name": "Active Alert", }, "chem_calcium_harness": { - "new_key": VALUE.CALCIUM_HARNESS, + "new_key": VALUE.CALCIUM_HARDNESS, + }, + "calcium_harness": { + "new_key": VALUE.CALCIUM_HARDNESS, }, "chem_current_orp": { "new_key": VALUE.ORP_NOW, diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 3b45aa699d3..253d16610e4 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -6,7 +6,11 @@ import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.common import ( + ON_OFF, + ScreenLogicCommunicationError, + ScreenLogicError, +) from screenlogicpy.const.data import ATTR from screenlogicpy.const.msg import CODE @@ -170,8 +174,10 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): await self._async_set_circuit(ON_OFF.OFF) async def _async_set_circuit(self, state: ON_OFF) -> None: - if not await self.gateway.async_set_circuit(self._data_key, state.value): + try: + await self.gateway.async_set_circuit(self._data_key, state.value) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {state.value}" - ) + f"Failed to set_circuit {self._data_key} {state.value}: {sle.msg}" + ) from sle _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 69bed1af700..434b8921bc2 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.4"] + "requirements": ["screenlogicpy==0.10.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index a52e894c72b..091d377a56b 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from screenlogicpy.const.common import ScreenLogicCommunicationError, ScreenLogicError from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -15,6 +16,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN @@ -32,7 +34,6 @@ class ScreenLogicNumberRequiredMixin: """Describes a required mixin for a ScreenLogic number entity.""" set_value_name: str - set_value_args: tuple[tuple[str | int, ...], ...] @dataclass @@ -47,20 +48,12 @@ class ScreenLogicNumberDescription( SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -113,7 +106,6 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): f"set_value_name '{entity_description.set_value_name}' is not a coroutine" ) self._set_value_func: Callable[..., Awaitable[bool]] = func - self._set_value_args = entity_description.set_value_args self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -138,21 +130,14 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Current API requires certain values to be set at the same time. This - # gathers the existing values and updates the particular value being - # set by this entity. - args = {} - for data_path in self._set_value_args: - data_key = data_path[-1] - args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) - # Current API requires int values for the currently supported numbers. value = int(value) - args[self._data_key] = value - - if await self._set_value_func(*args.values()): - _LOGGER.debug("Set '%s' to %s", self._data_key, value) - await self._async_refresh() - else: - _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) + try: + await self._set_value_func(**{self._data_key: value}) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: + raise HomeAssistantError( + f"Failed to set '{self._data_key}' to {value}: {sle.msg}" + ) from sle + _LOGGER.debug("Set '%s' to %s", self._data_key, value) + await self._async_refresh() diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index bbcf8458014..5d4efc55883 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -139,7 +139,7 @@ SUPPORTED_INTELLICHEM_SENSORS = [ ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), - key=VALUE.CALCIUM_HARNESS, + key=VALUE.CALCIUM_HARDNESS, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index c11bb37294f..1cbab23d843 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -13,7 +13,7 @@ from homeassistant.components.blueprint import ( is_blueprint_instance_config, ) from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_DEFAULT, @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, async_validate_actions_config, diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 39a52e57b10..069037e53a0 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -8,6 +8,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -33,11 +38,15 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In( - { - TYPE_ASTRONOMICAL: "Astronomical", - TYPE_METEOROLOGICAL: "Meteorological", - } + vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): SelectSelector( + SelectSelectorConfig( + translation_key="season_type", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_ASTRONOMICAL, + TYPE_METEOROLOGICAL, + ], + ) ) }, ), diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index 162daddd412..b0313d227a3 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -23,5 +23,13 @@ } } } + }, + "selector": { + "season_type": { + "options": { + "astronomical": "Astronomical", + "meteorological": "Meteorological" + } + } } } diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 9f20c051576..0a60fc4a85d 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -23,22 +23,24 @@ def async_handle_api_call( ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: """Decorate api calls.""" - async def wrap_api_call(*args: Any, **kwargs: Any) -> None: + async def wrap_api_call(entity: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap services for api calls.""" res: bool = False try: async with asyncio.timeout(TIMEOUT): - res = await function(*args, **kwargs) + res = await function(entity, *args, **kwargs) except SENSIBO_ERRORS as err: raise HomeAssistantError from err - LOGGER.debug("Result %s for entity %s with arguments %s", res, args[0], kwargs) - entity: SensiboDeviceBaseEntity = args[0] + LOGGER.debug("Result %s for entity %s with arguments %s", res, entity, kwargs) if res is not True: raise HomeAssistantError(f"Could not execute service for {entity.name}") - if kwargs.get("key") is not None and kwargs.get("value") is not None: - setattr(entity.device_data, kwargs["key"], kwargs["value"]) - LOGGER.debug("Debug check key %s is now %s", kwargs["key"], kwargs["value"]) + if ( + isinstance(key := kwargs.get("key"), str) + and (value := kwargs.get("value")) is not None + ): + setattr(entity.device_data, key, value) + LOGGER.debug("Debug check key %s is now %s", key, value) entity.async_write_ha_state() await entity.coordinator.async_request_refresh() diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 7ea18304164..6f0001e97ce 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -26,6 +26,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, + "data_description": { + "host": "The hostname or IP address of your SFR device." + }, "description": "Setting the credentials is optional, but enables additional functionality." } } diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b29fdcc6d19..553d32f8e48 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -49,7 +49,6 @@ from .utils import ( get_block_device_sleep_period, get_coap_context, get_device_entry_gen, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, ) @@ -266,9 +265,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo if sleep_period is None: data = {**entry.data} - data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period( - device.config - ) or get_rpc_device_wakeup_period(device.status) + data[CONF_SLEEP_PERIOD] = get_rpc_device_wakeup_period(device.status) hass.config_entries.async_update_entry(entry, data=data) hass.async_create_task(_async_rpc_device_setup()) diff --git a/homeassistant/components/shelly/bluetooth/scanner.py b/homeassistant/components/shelly/bluetooth/scanner.py index 7c0dc3c792a..3ada1ce55f5 100644 --- a/homeassistant/components/shelly/bluetooth/scanner.py +++ b/homeassistant/components/shelly/bluetooth/scanner.py @@ -6,13 +6,16 @@ from typing import Any from aioshelly.ble import parse_ble_scan_result_event from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner +from homeassistant.components.bluetooth import ( + MONOTONIC_TIME, + HomeAssistantRemoteScanner, +) from homeassistant.core import callback from ..const import LOGGER -class ShellyBLEScanner(BaseHaRemoteScanner): +class ShellyBLEScanner(HomeAssistantRemoteScanner): """Scanner for shelly.""" @callback diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index d855e8b238b..6a592c904f6 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -42,12 +42,7 @@ from .const import ( ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import ( - async_remove_shelly_entity, - get_device_entry_gen, - get_rpc_key_ids, - is_relay_used_as_actuator, -) +from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( @@ -131,7 +126,9 @@ def async_setup_rpc_entry( for id_ in climate_key_ids: climate_ids.append(id_) - if is_relay_used_as_actuator(id_, coordinator.mac, coordinator.device.config): + if coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is used as the thermostat actuator, + # we need to remove a switch entity unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "switch", unique_id) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 6cde265bc25..98233d27b22 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -12,7 +12,6 @@ from aioshelly.exceptions import ( InvalidAuthError, ) from aioshelly.rpc_device import RpcDevice -from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -24,7 +23,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import ( - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DOMAIN, @@ -32,14 +30,13 @@ from .const import ( MODEL_WALL_DISPLAY, BLEScannerMode, ) -from .coordinator import async_reconnect_soon, get_entry_data +from .coordinator import async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, get_info_auth, get_info_gen, get_model_name, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, mac_address_from_name, @@ -78,9 +75,7 @@ async def validate_input( ) await rpc_device.shutdown() - sleep_period = get_rpc_device_sleep_period( - rpc_device.config - ) or get_rpc_device_wakeup_period(rpc_device.status) + sleep_period = get_rpc_device_wakeup_period(rpc_device.status) return { "title": rpc_device.name, @@ -383,15 +378,6 @@ class OptionsFlowHandler(OptionsFlow): ) -> FlowResult: """Handle options flow.""" if user_input is not None: - entry_data = get_entry_data(self.hass)[self.config_entry.entry_id] - if user_input[CONF_BLE_SCANNER_MODE] != BLEScannerMode.DISABLED and ( - not entry_data.rpc - or AwesomeVersion(entry_data.rpc.device.version) < BLE_MIN_VERSION - ): - return self.async_abort( - reason="ble_unsupported", - description_placeholders={"ble_min_version": BLE_MIN_VERSION}, - ) return self.async_create_entry(title="", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index a90aba8db62..ca1c450c9fa 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -22,7 +22,6 @@ from aioshelly.const import ( MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, ) -from awesomeversion import AwesomeVersion DOMAIN: Final = "shelly" @@ -33,9 +32,6 @@ CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") -# Firmware 1.11.0 release date, this firmware supports light transition -LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 - # max light transition time in milliseconds MAX_TRANSITION_TIME: Final = 5000 @@ -187,8 +183,6 @@ ENTRY_RELOAD_COOLDOWN = 60 SHELLY_GAS_MODELS = [MODEL_GAS] -BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") - CONF_BLE_SCANNER_MODE = "ble_scanner_mode" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d1f9d6943bf..a7659ecc392 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -13,7 +13,6 @@ from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType -from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -33,7 +32,6 @@ from .const import ( ATTR_DEVICE, ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, @@ -587,14 +585,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) return - if AwesomeVersion(self.device.version) < BLE_MIN_VERSION: - LOGGER.error( - "BLE not supported on device %s with firmware %s; upgrade to %s", - self.name, - self.device.version, - BLE_MIN_VERSION, - ) - return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 829a60b3a9e..2dfc5b497b1 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -24,11 +24,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DUAL_MODE_LIGHT_MODELS, - FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, - LIGHT_TRANSITION_MIN_FIRMWARE_DATE, LOGGER, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, @@ -155,12 +153,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.EFFECT if coordinator.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(coordinator.device.settings.get("fw", "")) - if ( - match is not None - and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE - ): - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._attr_supported_features |= LightEntityFeature.TRANSITION @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index b8185712d31..b56ce07bc30 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==6.1.0"], + "requirements": ["aioshelly==7.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b12ad3e4823..330dd246c47 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -6,6 +6,9 @@ "description": "Before setup, battery-powered devices must be woken up, you can now wake the device up using a button on it.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Shelly device to connect to." } }, "credentials": { @@ -68,9 +71,6 @@ "ble_scanner_mode": "Bluetooth scanner mode" } } - }, - "abort": { - "ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer." } }, "selector": { diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 35429c858f5..5a398182e4d 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -118,8 +118,9 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if coordinator.device.shelly["relay_operational"]: - # Wall Display in relay mode, we need to remove a climate entity + if not coordinator.device.shelly.get("relay_in_thermostat", False): + # Wall Display relay is not used as the thermostat actuator, + # we need to remove a climate entity unique_id = f"{coordinator.mac}-thermostat:{id_}" async_remove_shelly_entity(hass, "climate", unique_id) else: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0209dc63aa8..b53e3153a09 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -267,15 +267,6 @@ def get_block_device_sleep_period(settings: dict[str, Any]) -> int: return sleep_period * 60 # minutes to seconds -def get_rpc_device_sleep_period(config: dict[str, Any]) -> int: - """Return the device sleep period in seconds or 0 for non sleeping devices. - - sys.sleep.wakeup_period value is deprecated and not available in Shelly - firmware 1.0.0 or later. - """ - return cast(int, config["sys"].get("sleep", {}).get("wakeup_period", 0)) - - def get_rpc_device_wakeup_period(status: dict[str, Any]) -> int: """Return the device wakeup period in seconds or 0 for non sleeping devices.""" return cast(int, status["sys"].get("wakeup_period", 0)) @@ -430,10 +421,3 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: return None return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL - - -def is_relay_used_as_actuator(relay_id: int, mac: str, config: dict[str, Any]) -> bool: - """Return True if an internal relay is used as the thermostat actuator.""" - return f"{mac}/c/switch:{relay_id}".lower() in config[f"thermostat:{relay_id}"].get( - "actuator", "" - ) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 7b57fa1fc32..b1bd2c8e9d6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable from datetime import timedelta from typing import Any, cast @@ -336,7 +336,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def extract_system(func: Callable) -> Callable: + def extract_system( + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]] + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct system for a service call.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index f5dc6c16c88..16e5d7408c4 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -19,6 +19,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, + "data_description": { + "host": "The hostname or IP address of your SMA device." + }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" } diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 16558d2c795..f07c293939a 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -497,14 +497,16 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Return the unit of measurement.""" return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit] - def _determine_swing_modes(self) -> list[str]: + def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" + supported_swings = None supported_modes = self._device.status.attributes[ Attribute.supported_fan_oscillation_modes ][0] - supported_swings = [ - FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes - ] + if supported_modes is not None: + supported_swings = [ + FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes + ] return supported_swings async def async_set_swing_mode(self, swing_mode: str) -> None: @@ -526,10 +528,10 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" - supported_modes = self._device.status.attributes[ + supported_modes: list | None = self._device.status.attributes[ "supportedAcOptionalMode" ].value - if WINDFREE in supported_modes: + if supported_modes and WINDFREE in supported_modes: return [WINDFREE] return None diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ebf80e22909..6c814b781b2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -12,10 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105..d3ba407fa40 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_UPDATE_SMARTY diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 6b960409305..87600650551 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -8,6 +8,7 @@ from email.mime.text import MIMEText import email.utils import logging import os +from pathlib import Path import smtplib import voluptuous as vol @@ -31,6 +32,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -193,10 +195,15 @@ class MailNotificationService(BaseNotificationService): if data := kwargs.get(ATTR_DATA): if ATTR_HTML in data: msg = _build_html_msg( - message, data[ATTR_HTML], images=data.get(ATTR_IMAGES, []) + self.hass, + message, + data[ATTR_HTML], + images=data.get(ATTR_IMAGES, []), ) else: - msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES, [])) + msg = _build_multipart_msg( + self.hass, message, images=data.get(ATTR_IMAGES, []) + ) else: msg = _build_text_msg(message) @@ -241,13 +248,34 @@ def _build_text_msg(message): return MIMEText(message) -def _attach_file(atch_name, content_id=""): +def _attach_file(hass, atch_name, content_id=""): """Create a message attachment. If MIMEImage is successful and content_id is passed (HTML), add images in-line. Otherwise add them as attachments. """ try: + file_path = Path(atch_name).parent + if os.path.exists(file_path) and not hass.config.is_allowed_path( + str(file_path) + ): + allow_list = "allowlist_external_dirs" + file_name = os.path.basename(atch_name) + url = "https://www.home-assistant.io/docs/configuration/basic/" + raise ServiceValidationError( + f"Cannot send email with attachment '{file_name}' " + f"from directory '{file_path}' which is not secure to load data from. " + f"Only folders added to `{allow_list}` are accessible. " + f"See {url} for more information.", + translation_domain=DOMAIN, + translation_key="remote_path_not_allowed", + translation_placeholders={ + "allow_list": allow_list, + "file_path": file_path, + "file_name": file_name, + "url": url, + }, + ) with open(atch_name, "rb") as attachment_file: file_bytes = attachment_file.read() except FileNotFoundError: @@ -277,22 +305,22 @@ def _attach_file(atch_name, content_id=""): return attachment -def _build_multipart_msg(message, images): +def _build_multipart_msg(hass, message, images): """Build Multipart message with images as attachments.""" - _LOGGER.debug("Building multipart email with image attachment(s)") + _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)") msg = MIMEMultipart() body_txt = MIMEText(message) msg.attach(body_txt) for atch_name in images: - attachment = _attach_file(atch_name) + attachment = _attach_file(hass, atch_name) if attachment: msg.attach(attachment) return msg -def _build_html_msg(text, html, images): +def _build_html_msg(hass, text, html, images): """Build Multipart message with in-line images and rich HTML (UTF-8).""" _LOGGER.debug("Building HTML rich email") msg = MIMEMultipart("related") @@ -303,7 +331,7 @@ def _build_html_msg(text, html, images): for atch_name in images: name = os.path.basename(atch_name) - attachment = _attach_file(atch_name, name) + attachment = _attach_file(hass, atch_name, name) if attachment: msg.attach(attachment) return msg diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json index b711c2f2009..37250fa6447 100644 --- a/homeassistant/components/smtp/strings.json +++ b/homeassistant/components/smtp/strings.json @@ -4,5 +4,10 @@ "name": "[%key:common::action::reload%]", "description": "Reloads smtp notify services." } + }, + "exceptions": { + "remote_path_not_allowed": { + "message": "Cannot send email with attachment \"{file_name}\" from directory \"{file_path}\" which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information." + } } } diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0d51c7543f1..b5673910595 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, + "data_description": { + "host": "The hostname or IP address of your Snapcast server." + }, "title": "[%key:common::action::connect%]" } }, diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 62e923a766d..5f5e2ae7a5f 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -6,6 +6,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "The prefix to be used for your Solar-Log sensors" + }, + "data_description": { + "host": "The hostname or IP address of your Solar-Log device." } } }, diff --git a/homeassistant/components/soma/strings.json b/homeassistant/components/soma/strings.json index 931a33fff56..abf87b3dde2 100644 --- a/homeassistant/components/soma/strings.json +++ b/homeassistant/components/soma/strings.json @@ -16,8 +16,10 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "description": "Please enter connection settings of your SOMA Connect.", - "title": "SOMA Connect" + "data_description": { + "host": "The hostname or IP address of your SOMA Connect." + }, + "description": "Please enter connection settings of your SOMA Connect." } } } diff --git a/homeassistant/components/somfy_mylink/strings.json b/homeassistant/components/somfy_mylink/strings.json index 2609e8d893e..90489c0ba34 100644 --- a/homeassistant/components/somfy_mylink/strings.json +++ b/homeassistant/components/somfy_mylink/strings.json @@ -8,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "system_id": "System ID" + }, + "data_description": { + "host": "The hostname or IP address of your Somfy MyLink hub." } } }, diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7af95aab38c..9fc11f7788a 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Bose SoundTouch device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 3fdc6b2c079..c4e6db4c623 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -362,6 +362,8 @@ class SQLSensor(ManualTriggerSensorEntity): self._query, redact_credentials(str(err)), ) + sess.rollback() + sess.close() return for res in result.mappings(): diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2c96046b97c..b155c7eddc0 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_registry import async_get -from .const import DEFAULT_PORT, DOMAIN +from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -49,9 +49,15 @@ def _base_schema(discovery_info=None): ) else: base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int}) + base_schema.update( - {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + { + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_HTTPS, default=False): bool, + } ) + return vol.Schema(base_schema) @@ -105,6 +111,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data[CONF_PORT], data.get(CONF_USERNAME), data.get(CONF_PASSWORD), + https=data[CONF_HTTPS], ) try: diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index d8b67504397..38a9ef7668f 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -6,3 +6,4 @@ PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" DISCOVERY_TASK = "discovery_task" DEFAULT_PORT = 9000 SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +CONF_HTTPS = "https" diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 43c2868dd69..83ca3ff1b00 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.6.3"] + "requirements": ["pysqueezebox==0.7.1"] } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 03457c6a5c0..4e3d71eca24 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -52,6 +52,7 @@ from .browse_media import ( media_source_content_filter, ) from .const import ( + CONF_HTTPS, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -126,6 +127,7 @@ async def async_setup_entry( password = config.get(CONF_PASSWORD) host = config[CONF_HOST] port = config[CONF_PORT] + https = config.get(CONF_HTTPS, False) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) @@ -134,7 +136,7 @@ async def async_setup_entry( session = async_get_clientsession(hass) _LOGGER.debug("Creating LMS object for %s", host) - lms = Server(session, host, port, username, password) + lms = Server(session, host, port, username, password, https=https) async def _discovery(now=None): """Discover squeezebox players by polling server.""" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 87881e3414b..fd232851e8a 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Media Server." } }, "edit": { @@ -13,7 +16,8 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "https": "Connect over https (requires reverse proxy)" } } }, diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index d1ac46148a7..1a125da6a6b 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -10,6 +10,6 @@ LOGGER = logging.getLogger(__package__) class StookwijzerState(StrEnum): """Stookwijzer states for sensor entity.""" - CODE_YELLOW = "code_yellow" - CODE_ORANGE = "code_orange" - CODE_RED = "code_red" + BLUE = "blauw" + ORANGE = "oranje" + RED = "rood" diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 85996bb6394..e29606cb191 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -24,9 +24,8 @@ async def async_get_config_entry_diagnostics( return { "state": client.state, "last_updated": last_updated, - "alert": client.alert, - "air_quality_index": client.lki, - "windspeed_bft": client.windspeed_bft, - "windspeed_ms": client.windspeed_ms, - "forecast": client.forecast, + "lqi": client.lqi, + "windspeed": client.windspeed, + "weather": client.weather, + "concentrations": client.concentrations, } diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 91504ef923f..dbf902b1e1e 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.4.2"] + "requirements": ["stookwijzer==1.3.0"] } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index e8d03499a8e..312f8bdd02d 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( class StookwijzerSensor(SensorEntity): """Defines a Stookwijzer binary sensor.""" - _attr_attribution = "Data provided by atlasleefomgeving.nl" + _attr_attribution = "Data provided by stookwijzer.nu" _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True _attr_name = None @@ -43,9 +43,9 @@ class StookwijzerSensor(SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{entry.entry_id}")}, name="Stookwijzer", - manufacturer="Atlas Leefomgeving", + manufacturer="stookwijzer.nu", entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.atlasleefomgeving.nl/stookwijzer", + configuration_url="https://www.stookwijzer.nu", ) def update(self) -> None: diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 62006f878c8..549673165ec 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -13,9 +13,9 @@ "sensor": { "stookwijzer": { "state": { - "code_yellow": "Code yellow", - "code_orange": "Code orange", - "code_red": "Code red" + "blauw": "Blue", + "oranje": "Orange", + "rood": "Red" } } } diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 43465fb99ae..4a974077592 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,19 +41,19 @@ def setup_platform( class StreamlabsLocationData: """Track and query location data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the location data.""" self._location_id = location_id self._client = client self._is_away = None @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) - def update(self): + def update(self) -> None: """Query and store location data.""" location = self._client.get_location(self._location_id) self._is_away = location["homeAway"] == "away" - def is_away(self): + def is_away(self) -> bool | None: """Return whether away more is enabled.""" return self._is_away @@ -59,19 +61,21 @@ class StreamlabsLocationData: class StreamlabsAwayMode(BinarySensorEntity): """Monitor the away mode state.""" - def __init__(self, location_name, streamlabs_location_data): + def __init__( + self, location_name: str, streamlabs_location_data: StreamlabsLocationData + ) -> None: """Initialize the away mode device.""" self._location_name = location_name self._streamlabs_location_data = streamlabs_location_data self._is_away = None @property - def name(self): + def name(self) -> str: """Return the name for away mode.""" return f"{self._location_name} {NAME_AWAY_MODE}" @property - def is_on(self): + def is_on(self) -> bool | None: """Return if away mode is on.""" return self._streamlabs_location_data.is_away() diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 42cf2bb588f..42e551c5c11 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import timedelta +from streamlabswater.streamlabswater import StreamlabsClient + from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant @@ -48,7 +50,7 @@ def setup_platform( class StreamlabsUsageData: """Track and query usage data.""" - def __init__(self, location_id, client): + def __init__(self, location_id: str, client: StreamlabsClient) -> None: """Initialize the usage data.""" self._location_id = location_id self._client = client @@ -57,22 +59,22 @@ class StreamlabsUsageData: self._this_year = None @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) - def update(self): + def update(self) -> None: """Query and store usage data.""" water_usage = self._client.get_water_usage_summary(self._location_id) self._today = round(water_usage["today"], 1) self._this_month = round(water_usage["thisMonth"], 1) self._this_year = round(water_usage["thisYear"], 1) - def get_daily_usage(self): + def get_daily_usage(self) -> float | None: """Return the day's usage.""" return self._today - def get_monthly_usage(self): + def get_monthly_usage(self) -> float | None: """Return the month's usage.""" return self._this_month - def get_yearly_usage(self): + def get_yearly_usage(self) -> float | None: """Return the year's usage.""" return self._this_year @@ -83,7 +85,9 @@ class StreamLabsDailyUsage(SensorEntity): _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.GALLONS - def __init__(self, location_name, streamlabs_usage_data): + def __init__( + self, location_name: str, streamlabs_usage_data: StreamlabsUsageData + ) -> None: """Initialize the daily water usage device.""" self._location_name = location_name self._streamlabs_usage_data = streamlabs_usage_data @@ -95,7 +99,7 @@ class StreamLabsDailyUsage(SensorEntity): return f"{self._location_name} {NAME_DAILY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current daily usage.""" return self._streamlabs_usage_data.get_daily_usage() @@ -113,7 +117,7 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_MONTHLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current monthly usage.""" return self._streamlabs_usage_data.get_monthly_usage() @@ -127,6 +131,6 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): return f"{self._location_name} {NAME_YEARLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float | None: """Return the current yearly usage.""" return self._streamlabs_usage_data.get_yearly_usage() diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 862f59d5f6d..cd5aef312ce 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -6,8 +6,9 @@ from collections.abc import AsyncIterable, Coroutine import logging from typing import Any +from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_prepare_setup_platform diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py new file mode 100644 index 00000000000..f77633f4953 --- /dev/null +++ b/homeassistant/components/sunweg/__init__.py @@ -0,0 +1,193 @@ +"""The Sun WEG inverter sensor integration.""" +import datetime +import json +import logging + +from sunweg.api import APIHelper +from sunweg.plant import Plant + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType +from homeassistant.util import Throttle + +from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Load the saved entities.""" + api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + if not await hass.async_add_executor_job(api.authenticate): + _LOGGER.error("Username or Password may be incorrect!") + return False + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( + api, entry.data[CONF_PLANT_ID] + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SunWEGData: + """The class for handling data retrieval.""" + + def __init__( + self, + api: APIHelper, + plant_id: int, + ) -> None: + """Initialize the probe.""" + + self.api = api + self.plant_id = plant_id + self.data: Plant = None + self.previous_values: dict = {} + + @Throttle(SCAN_INTERVAL) + def update(self) -> None: + """Update probe data.""" + _LOGGER.debug("Updating data for plant %s", self.plant_id) + try: + self.data = self.api.plant(self.plant_id) + for inverter in self.data.inverters: + self.api.complete_inverter(inverter) + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from SunWEG server") + _LOGGER.debug("Finished updating data for plant %s", self.plant_id) + + def get_api_value( + self, + variable: str, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ): + """Retrieve from a Plant the desired variable value.""" + if device_type == "total": + return self.data.__dict__.get(variable) + + inverter_list = [i for i in self.data.inverters if i.id == inverter_id] + if len(inverter_list) == 0: + return None + inverter = inverter_list[0] + + if device_type == "inverter": + return inverter.__dict__.get(variable) + if device_type == "phase": + for phase in inverter.phases: + if phase.name == deep_name: + return phase.__dict__.get(variable) + elif device_type == "string": + for mppt in inverter.mppts: + for string in mppt.strings: + if string.name == deep_name: + return string.__dict__.get(variable) + return None + + def get_data( + self, + entity_description: SunWEGSensorEntityDescription, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> StateType | datetime.datetime: + """Get the data.""" + _LOGGER.debug( + "Data request for: %s", + entity_description.name, + ) + variable = entity_description.api_variable_key + previous_metric = entity_description.native_unit_of_measurement + api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) + previous_value = self.previous_values.get(variable) + return_value = api_value + if entity_description.api_variable_metric is not None: + entity_description.native_unit_of_measurement = self.get_api_value( + entity_description.api_variable_metric, + device_type, + inverter_id, + deep_name, + ) + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + entity_description.previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + and previous_metric == entity_description.native_unit_of_measurement + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + entity_description.name, + entity_description.previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(entity_description.previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug( + "%s - No drop detected, using API value", entity_description.name + ) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + # Scenarios: + # 1 - System has a genuine 0 value when it it first commissioned: + # - will return 0 until a non-zero value is registered + # 2 - System has been running fine but temporarily resets to 0 briefly + # at midnight: + # - will return the previous value + # 3 - HA is restarted during the midnight 'outage' - Not handled: + # - Previous value will not exist meaning 0 will be returned + # - This is an edge case that would be better handled by looking + # up the previous value of the entity from the recorder + if entity_description.never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return return_value diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py new file mode 100644 index 00000000000..cd24a4722e9 --- /dev/null +++ b/homeassistant/components/sunweg/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Sun WEG integration.""" +from sunweg.api import APIHelper +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_PLANT_ID, DOMAIN + + +class SunWEGConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow class.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialise sun weg server flow.""" + self.api: APIHelper = None + self.data: dict = {} + + @callback + def _async_show_user_form(self, errors=None) -> FlowResult: + """Show the form to the user.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + return self._async_show_user_form() + + # Initialise the library with the username & password + self.api = APIHelper(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + login_response = await self.hass.async_add_executor_job(self.api.authenticate) + + if not login_response: + return self._async_show_user_form({"base": "invalid_auth"}) + + # Store authentication info + self.data = user_input + return await self.async_step_plant() + + async def async_step_plant(self, user_input=None) -> FlowResult: + """Handle adding a "plant" to Home Assistant.""" + plant_list = await self.hass.async_add_executor_job(self.api.listPlants) + + if len(plant_list) == 0: + return self.async_abort(reason="no_plants") + + plants = {plant.id: plant.name for plant in plant_list} + + if user_input is None and len(plant_list) > 1: + data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + + return self.async_show_form(step_id="plant", data_schema=data_schema) + + if user_input is None and len(plant_list) == 1: + user_input = {CONF_PLANT_ID: plant_list[0].id} + + user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] + await self.async_set_unique_id(user_input[CONF_PLANT_ID]) + self._abort_if_unique_id_configured() + self.data.update(user_input) + return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py new file mode 100644 index 00000000000..12ecfb3849c --- /dev/null +++ b/homeassistant/components/sunweg/const.py @@ -0,0 +1,12 @@ +"""Define constants for the Sun WEG component.""" +from homeassistant.const import Platform + +CONF_PLANT_ID = "plant_id" + +DEFAULT_PLANT_ID = 0 + +DEFAULT_NAME = "Sun WEG" + +DOMAIN = "sunweg" + +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json new file mode 100644 index 00000000000..271a16236d3 --- /dev/null +++ b/homeassistant/components/sunweg/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sunweg", + "name": "Sun WEG", + "codeowners": ["@rokam"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunweg/", + "iot_class": "cloud_polling", + "loggers": ["sunweg"], + "requirements": ["sunweg==2.0.0"] +} diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor.py new file mode 100644 index 00000000000..157595219e8 --- /dev/null +++ b/homeassistant/components/sunweg/sensor.py @@ -0,0 +1,177 @@ +"""Read status of SunWEG inverters.""" +from __future__ import annotations + +import datetime +import logging +from types import MappingProxyType +from typing import Any + +from sunweg.api import APIHelper +from sunweg.device import Inverter +from sunweg.plant import Plant + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import SunWEGData +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN +from .sensor_types.inverter import INVERTER_SENSOR_TYPES +from .sensor_types.phase import PHASE_SENSOR_TYPES +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription +from .sensor_types.string import STRING_SENSOR_TYPES +from .sensor_types.total import TOTAL_SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +def get_device_list( + api: APIHelper, config: MappingProxyType[str, Any] +) -> tuple[list[Inverter], int]: + """Retrieve the device list for the selected plant.""" + plant_id = int(config[CONF_PLANT_ID]) + + if plant_id == DEFAULT_PLANT_ID: + plant_info: list[Plant] = api.listPlants() + plant_id = plant_info[0].id + + devices: list[Inverter] = [] + # Get a list of devices for specified plant to add sensors for. + for inverter in api.plant(plant_id).inverters: + api.complete_inverter(inverter) + devices.append(inverter) + return (devices, plant_id) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SunWEG sensor.""" + name = config_entry.data[CONF_NAME] + + probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] + + devices, plant_id = await hass.async_add_executor_job( + get_device_list, probe.api, config_entry.data + ) + + entities = [ + SunWEGInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, + device_type="total", + ) + for description in TOTAL_SENSOR_TYPES + ] + + # Add sensors for each device in the specified plant. + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name}", + unique_id=f"{device.sn}-{description.key}", + description=description, + device_type="inverter", + inverter_id=device.id, + ) + for device in devices + for description in INVERTER_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {phase.name}", + unique_id=f"{device.sn}-{phase.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type="phase", + deep_name=phase.name, + ) + for device in devices + for phase in device.phases + for description in PHASE_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {string.name}", + unique_id=f"{device.sn}-{string.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type="string", + deep_name=string.name, + ) + for device in devices + for mppt in device.mppts + for string in mppt.strings + for description in STRING_SENSOR_TYPES + ] + ) + + async_add_entities(entities, True) + + +class SunWEGInverter(SensorEntity): + """Representation of a SunWEG Sensor.""" + + entity_description: SunWEGSensorEntityDescription + + def __init__( + self, + probe: SunWEGData, + name: str, + unique_id: str, + description: SunWEGSensorEntityDescription, + device_type: str, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> None: + """Initialize a sensor.""" + self.probe = probe + self.entity_description = description + self.device_type = device_type + self.inverter_id = inverter_id + self.deep_name = deep_name + + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = ( + description.icon if description.icon is not None else "mdi:solar-power" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(probe.plant_id))}, + manufacturer="SunWEG", + name=name, + ) + + @property + def native_value( + self, + ) -> StateType | datetime.datetime: + """Return the state of the sensor.""" + return self.probe.get_data( + self.entity_description, + device_type=self.device_type, + inverter_id=self.inverter_id, + deep_name=self.deep_name, + ) + + def update(self) -> None: + """Get the latest data from the Sun WEG API and updates the state.""" + self.probe.update() diff --git a/homeassistant/components/sunweg/sensor_types/__init__.py b/homeassistant/components/sunweg/sensor_types/__init__.py new file mode 100644 index 00000000000..f370fddd16b --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/__init__.py @@ -0,0 +1 @@ +"""Sensor types for supported Sun WEG systems.""" diff --git a/homeassistant/components/sunweg/sensor_types/inverter.py b/homeassistant/components/sunweg/sensor_types/inverter.py new file mode 100644 index 00000000000..abb7e224836 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/inverter.py @@ -0,0 +1,69 @@ +"""SunWEG Sensor definitions for the Inverter type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) + +from .sensor_entity_description import SunWEGSensorEntityDescription + +INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_variable_key="_today_energy", + api_variable_metric="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_variable_key="_total_energy", + api_variable_metric="_total_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_variable_key="_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_variable_key="_power", + api_variable_metric="_power_metric", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_variable_key="_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:temperature-celsius", + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_power_factor", + name="Power Factor", + api_variable_key="_power_factor", + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/phase.py b/homeassistant/components/sunweg/sensor_types/phase.py new file mode 100644 index 00000000000..ca6b9374e0d --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/phase.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the Phase type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py new file mode 100644 index 00000000000..c3a00df6b6f --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -0,0 +1,23 @@ +"""Sensor Entity Description for the SunWEG integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class SunWEGRequiredKeysMixin: + """Mixin for required keys.""" + + api_variable_key: str + + +@dataclass +class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): + """Describes SunWEG sensor entity.""" + + api_variable_metric: str | None = None + previous_value_drop_threshold: float | None = None + never_resets: bool = False + icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor_types/string.py b/homeassistant/components/sunweg/sensor_types/string.py new file mode 100644 index 00000000000..d3ee0a43c21 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/string.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the String type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor_types/total.py new file mode 100644 index 00000000000..da874be7a24 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/total.py @@ -0,0 +1,54 @@ +"""SunWEG Sensor definitions for Totals.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfEnergy, UnitOfPower + +from .sensor_entity_description import SunWEGSensorEntityDescription + +TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_variable_key="_saving", + icon="mdi:cash", + native_unit_of_measurement="R$", + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_variable_key="_today_energy", + api_variable_metric="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SunWEGSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_variable_key="_total_power", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + SunWEGSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_variable_key="_total_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="kwh_per_kwp", + name="kWh por kWp", + api_variable_key="_kwh_per_kwp", + ), + SunWEGSensorEntityDescription( + key="last_update", + name="Last Update", + api_variable_key="_last_update", + device_class=SensorDeviceClass.DATE, + ), +) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json new file mode 100644 index 00000000000..3a910e62940 --- /dev/null +++ b/homeassistant/components/sunweg/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "no_plants": "No plants have been found on this account" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Select your plant" + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "title": "Enter your Sun WEG information" + } + } + } +} diff --git a/homeassistant/components/switchbee/strings.json b/homeassistant/components/switchbee/strings.json index 2abeee6dd7e..858bda35c0f 100644 --- a/homeassistant/components/switchbee/strings.json +++ b/homeassistant/components/switchbee/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your SwitchBee device." } } }, diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 16db365f708..3f30fe9b4e9 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -153,8 +153,7 @@ class SynologyPhotosMediaSource(MediaSource): ret = [] for album_item in album_items: mime_type, _ = mimetypes.guess_type(album_item.file_name) - assert isinstance(mime_type, str) - if mime_type.startswith("image/"): + if isinstance(mime_type, str) and mime_type.startswith("image/"): # Force small small thumbnails album_item.thumbnail_size = "sm" ret.append( diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f7ae9c9f238..4ed06119577 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -10,6 +10,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Synology NAS." } }, "2sa": { diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 42fc849a2cf..2ce81772774 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.3"] + "requirements": ["HATasmota==0.8.0"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index 21030b8c14b..48dbe51fd67 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -112,8 +112,11 @@ class TasmotaAvailability(TasmotaEntity): def __init__(self, **kwds: Any) -> None: """Initialize the availability mixin.""" - self._available = False super().__init__(**kwds) + if self._tasmota_entity.deep_sleep_enabled: + self._available = True + else: + self._available = False async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -122,6 +125,8 @@ class TasmotaAvailability(TasmotaEntity): async_subscribe_connection_status(self.hass, self.async_mqtt_connected) ) await super().async_added_to_hass() + if self._tasmota_entity.deep_sleep_enabled: + await self._tasmota_entity.poll_status() async def availability_updated(self, available: bool) -> None: """Handle updated availability.""" @@ -135,6 +140,8 @@ class TasmotaAvailability(TasmotaEntity): if not self.hass.is_stopping: if not mqtt_connected(self.hass): self._available = False + elif self._tasmota_entity.deep_sleep_enabled: + self._available = True self.async_write_ha_state() @property diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 7d150e95977..1d71e055e2e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -55,6 +55,7 @@ ATTR_CALLBACK_QUERY_ID = "callback_query_id" ATTR_CAPTION = "caption" ATTR_CHAT_ID = "chat_id" ATTR_CHAT_INSTANCE = "chat_instance" +ATTR_DATE = "date" ATTR_DISABLE_NOTIF = "disable_notification" ATTR_DISABLE_WEB_PREV = "disable_web_page_preview" ATTR_EDITED_MSG = "edited_message" @@ -991,6 +992,7 @@ class BaseTelegramBotEntity: event_data: dict[str, Any] = { ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, + ATTR_DATE: message.date, } if Filters.command.filter(message): # This is a command message - set event type to command and split data into command and args diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 94d1eee1b55..1587f754508 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -34,7 +34,6 @@ send_message: min: 1 max: 3600 unit_of_measurement: seconds - keyboard: example: '["/command1, /command2", "/command3"]' selector: @@ -50,6 +49,10 @@ send_message: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_photo: fields: @@ -117,6 +120,10 @@ send_photo: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_sticker: fields: @@ -177,6 +184,10 @@ send_sticker: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_animation: fields: @@ -240,6 +251,14 @@ send_animation: ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box send_video: fields: @@ -307,6 +326,10 @@ send_video: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_voice: fields: @@ -367,6 +390,10 @@ send_voice: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_document: fields: @@ -434,6 +461,10 @@ send_document: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_location: fields: @@ -480,6 +511,10 @@ send_location: example: "msg_to_edit" selector: text: + reply_to_message_id: + selector: + number: + mode: box send_poll: fields: @@ -516,6 +551,14 @@ send_poll: min: 1 max: 3600 unit_of_measurement: seconds + message_tag: + example: "msg_to_edit" + selector: + text: + reply_to_message_id: + selector: + number: + mode: box edit_message: fields: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 4dfe0a28d01..de5de685409 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -42,7 +42,11 @@ }, "message_tag": { "name": "Message tag", - "description": "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}." + "description": "Tag for sent message." + }, + "reply_to_message_id": { + "name": "Reply to message id", + "description": "Mark the message as a reply to a previous message." } } }, @@ -105,6 +109,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -163,6 +171,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -221,6 +233,14 @@ "inline_keyboard": { "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -283,6 +303,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -341,6 +365,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -403,6 +431,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -441,6 +473,10 @@ "message_tag": { "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, @@ -479,6 +515,14 @@ "timeout": { "name": "Timeout", "description": "Timeout for send poll. Will help with timeout errors (poor internet connection, etc)." + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + }, + "reply_to_message_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::reply_to_message_id::description%]" } } }, diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 1dbea7a0e6c..16c847f0077 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -18,7 +18,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]" }, - "title": "Pick endpoint." + "data_description": { + "host": "Hostname or IP address to Tellstick Net or Tellstick ZNet for Local API." + } } } }, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index d39fa56775a..8aeede42552 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -282,15 +282,6 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset_mode of the fan.""" - if self.preset_modes and preset_mode not in self.preset_modes: - _LOGGER.error( - "Received invalid preset_mode: %s for entity %s. Expected: %s", - preset_mode, - self.entity_id, - self.preset_modes, - ) - return - self._preset_mode = preset_mode if self._set_preset_mode_script: diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 982894eb17c..97bac988d16 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -6,6 +6,9 @@ "title": "Configure Tesla Wall Connector", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tesla Wall Connector." } } }, diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py new file mode 100644 index 00000000000..ac77c3cc09e --- /dev/null +++ b/homeassistant/components/tessie/__init__.py @@ -0,0 +1,62 @@ +"""Tessie integration.""" +from http import HTTPStatus +import logging + +from aiohttp import ClientError, ClientResponseError +from tessie_api import get_state_of_all_vehicles + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import TessieDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tessie config.""" + api_key = entry.data[CONF_ACCESS_TOKEN] + + try: + vehicles = await get_state_of_all_vehicles( + session=async_get_clientsession(hass), + api_key=api_key, + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) + return False + except ClientError as e: + raise ConfigEntryNotReady from e + + coordinators = [ + TessieDataUpdateCoordinator( + hass, + api_key=api_key, + vin=vehicle["vin"], + data=vehicle["last_state"], + ) + for vehicle in vehicles["results"] + if vehicle["last_state"] is not None + ] + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tessie Config.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py new file mode 100644 index 00000000000..4379a810309 --- /dev/null +++ b/homeassistant/components/tessie/config_flow.py @@ -0,0 +1,102 @@ +"""Config Flow for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from aiohttp import ClientConnectionError, ClientResponseError +from tessie_api import get_state_of_all_vehicles +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) + + +class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Tessie API connection.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get configuration from the user.""" + errors: dict[str, str] = {} + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Tessie", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESSIE_SCHEMA, + errors=errors, + ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get update API Key from the user.""" + errors: dict[str, str] = {} + assert self._reauth_entry + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=TESSIE_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py new file mode 100644 index 00000000000..dad9ba2345f --- /dev/null +++ b/homeassistant/components/tessie/const.py @@ -0,0 +1,21 @@ +"""Constants used by Tessie integration.""" +from __future__ import annotations + +from enum import StrEnum + +DOMAIN = "tessie" + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TessieStatus(StrEnum): + """Tessie status.""" + + ASLEEP = "asleep" + ONLINE = "online" + OFFLINE = "offline" diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py new file mode 100644 index 00000000000..7a2a8c71c56 --- /dev/null +++ b/homeassistant/components/tessie/coordinator.py @@ -0,0 +1,87 @@ +"""Tessie Data Coordinator.""" +from datetime import timedelta +from http import HTTPStatus +import logging +from typing import Any + +from aiohttp import ClientResponseError +from tessie_api import get_state + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import TessieStatus + +# This matches the update interval Tessie performs server side +TESSIE_SYNC_INTERVAL = 10 + +_LOGGER = logging.getLogger(__name__) + + +class TessieDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the Tessie API.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + vin: str, + data: dict[str, Any], + ) -> None: + """Initialize Tessie Data Update Coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tessie", + update_method=self.async_update_data, + update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), + ) + self.api_key = api_key + self.vin = vin + self.session = async_get_clientsession(hass) + self.data = self._flattern(data) + self.did_first_update = False + + async def async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Tessie API.""" + try: + vehicle = await get_state( + session=self.session, + api_key=self.api_key, + vin=self.vin, + use_cache=self.did_first_update, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.REQUEST_TIMEOUT: + # Vehicle is offline, only update state and dont throw error + self.data["state"] = TessieStatus.OFFLINE + return self.data + if e.status == HTTPStatus.UNAUTHORIZED: + # Auth Token is no longer valid + raise ConfigEntryAuthFailed from e + raise e + + self.did_first_update = True + if vehicle["state"] == TessieStatus.ONLINE: + # Vehicle is online, all data is fresh + return self._flattern(vehicle) + + # Vehicle is asleep, only update state + self.data["state"] = vehicle["state"] + return self.data + + def _flattern( + self, data: dict[str, Any], parent: str | None = None + ) -> dict[str, Any]: + """Flattern the data structure.""" + result = {} + for key, value in data.items(): + if parent: + key = f"{parent}-{key}" + if isinstance(value, dict): + result.update(self._flattern(value, key)) + else: + result[key] = value + return result diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py new file mode 100644 index 00000000000..4a14522a64c --- /dev/null +++ b/homeassistant/components/tessie/entity.py @@ -0,0 +1,45 @@ +"""Tessie parent entity class.""" + + +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS +from .coordinator import TessieDataUpdateCoordinator + + +class TessieEntity(CoordinatorEntity[TessieDataUpdateCoordinator]): + """Parent class for Tessie Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + key: str, + ) -> None: + """Initialize common aspects of a Tessie entity.""" + super().__init__(coordinator) + self.vin = coordinator.vin + self.key = key + + car_type = coordinator.data["vehicle_config-car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{self.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=coordinator.data["display_name"], + model=MODELS.get(car_type, car_type), + sw_version=coordinator.data["vehicle_state-car_version"], + hw_version=coordinator.data["vehicle_config-driver_assist"], + ) + + @property + def value(self) -> Any: + """Return value from coordinator data.""" + return self.coordinator.data[self.key] diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json new file mode 100644 index 00000000000..52fc8dd5be1 --- /dev/null +++ b/homeassistant/components/tessie/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tessie", + "name": "Tessie", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tessie", + "iot_class": "cloud_polling", + "loggers": ["tessie"], + "requirements": ["tessie-api==0.0.9"] +} diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py new file mode 100644 index 00000000000..1941d8ba162 --- /dev/null +++ b/homeassistant/components/tessie/sensor.py @@ -0,0 +1,225 @@ +"""Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN, TessieStatus +from .coordinator import TessieDataUpdateCoordinator +from .entity import TessieEntity + +PARALLEL_UPDATES = 0 + + +@dataclass +class TessieSensorEntityDescription(SensorEntityDescription): + """Describes Tessie Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + +DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="state", + options=[status.value for status in TessieStatus], + device_class=SensorDeviceClass.ENUM, + ), + TessieSensorEntityDescription( + key="charge_state-usable_battery_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + TessieSensorEntityDescription( + key="charge_state-charge_energy_added", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="charge_state-charger_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + TessieSensorEntityDescription( + key="charge_state-charger_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-charger_actual_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-charge_rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state-battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="drive_state-speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + ), + TessieSensorEntityDescription( + key="drive_state-power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="drive_state-shift_state", + icon="mdi:car-shift-pattern", + options=["p", "d", "r", "n"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda x: x.lower() if isinstance(x, str) else x, + ), + TessieSensorEntityDescription( + key="vehicle_state-odometer", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_fl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_fr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_rl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state-tpms_pressure_rr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state-inside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state-outside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state-driver_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state-passenger_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + coordinators = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSensorEntity(coordinator, description) + for coordinator in coordinators + for description in DESCRIPTIONS + if description.key in coordinator.data + ) + + +class TessieSensorEntity(TessieEntity, SensorEntity): + """Base class for Tessie metric sensors.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + coordinator: TessieDataUpdateCoordinator, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json new file mode 100644 index 00000000000..5d57075241c --- /dev/null +++ b/homeassistant/components/tessie/strings.json @@ -0,0 +1,99 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Enter your access token from [my.tessie.com/settings/api](https://my.tessie.com/settings/api)." + }, + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "[%key:component::tessie::config::step::user::description%]", + "title": "[%key:common::config_flow::title::reauth%]" + } + } + }, + "entity": { + "sensor": { + "state": { + "name": "Status", + "state": { + "online": "Online", + "asleep": "Asleep", + "offline": "Offline" + } + }, + "charge_state-usable_battery_level": { + "name": "Battery Level" + }, + "charge_state-charge_energy_added": { + "name": "Charge Energy Added" + }, + "charge_state-charger_power": { + "name": "Charger Power" + }, + "charge_state-charger_voltage": { + "name": "Charger Voltage" + }, + "charge_state-charger_actual_current": { + "name": "Charger Current" + }, + "charge_state-charge_rate": { + "name": "Charge Rate" + }, + "charge_state-battery_range": { + "name": "Battery Range" + }, + "drive_state-speed": { + "name": "Speed" + }, + "drive_state-power": { + "name": "Power" + }, + "drive_state-shift_state": { + "name": "Shift State", + "state": { + "p": "Park", + "d": "Drive", + "r": "Reverse", + "n": "Neutral" + } + }, + "vehicle_state-odometer": { + "name": "Odometer" + }, + "vehicle_state-tpms_pressure_fl": { + "name": "Tyre Pressure Front Left" + }, + "vehicle_state-tpms_pressure_fr": { + "name": "Tyre Pressure Front Right" + }, + "vehicle_state-tpms_pressure_rl": { + "name": "Tyre Pressure Rear Left" + }, + "vehicle_state-tpms_pressure_rr": { + "name": "Tyre Pressure Rear Right" + }, + "climate_state-inside_temp": { + "name": "Inside Temperature" + }, + "climate_state-outside_temp": { + "name": "Outside Temperature" + }, + "climate_state-driver_temp_setting": { + "name": "Driver Temperature Setting" + }, + "climate_state-passenger_temp_setting": { + "name": "Passenger Temperature Setting" + } + } + } +} diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index b48760f773d..a0a07d3cb00 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.4.5"] + "requirements": ["thermopro-ble==0.5.0"] } diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 814138dcb7f..c0e0303d76e 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -35,7 +35,7 @@ from .const import ( ATTR_DESCRIPTION, ATTR_DUE, ATTR_DUE_DATE, - ATTR_DUE_DATE_TIME, + ATTR_DUE_DATETIME, DOMAIN, TodoItemStatus, TodoListEntityFeature, @@ -73,7 +73,7 @@ TODO_ITEM_FIELDS = [ required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, ), TodoItemFieldDescription( - service_field=ATTR_DUE_DATE_TIME, + service_field=ATTR_DUE_DATETIME, validation=vol.All(cv.datetime, dt_util.as_local), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, @@ -89,9 +89,7 @@ TODO_ITEM_FIELDS = [ TODO_ITEM_FIELD_SCHEMA = { vol.Optional(desc.service_field): desc.validation for desc in TODO_ITEM_FIELDS } -TODO_ITEM_FIELD_VALIDATIONS = [ - cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATE_TIME) -] +TODO_ITEM_FIELD_VALIDATIONS = [cv.has_at_most_one_key(ATTR_DUE_DATE, ATTR_DUE_DATETIME)] def _validate_supported_features( diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 95e190cb3e3..a605f9fcba2 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -6,7 +6,7 @@ DOMAIN = "todo" ATTR_DUE = "due" ATTR_DUE_DATE = "due_date" -ATTR_DUE_DATE_TIME = "due_date_time" +ATTR_DUE_DATETIME = "due_datetime" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 390aa82753a..8ecc9e0ec86 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -29,7 +29,7 @@ add_item: example: "2023-11-17" selector: date: - due_date_time: + due_datetime: example: "2023-11-17 13:30:00" selector: datetime: @@ -65,7 +65,7 @@ update_item: example: "2023-11-17" selector: date: - due_date_time: + due_datetime: example: "2023-11-17 13:30:00" selector: datetime: @@ -86,3 +86,8 @@ remove_item: text: remove_completed_items: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index bca32f850eb..3da921a8f47 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -28,8 +28,8 @@ "name": "Due date", "description": "The date the to-do item is expected to be completed." }, - "due_date_time": { - "name": "Due date time", + "due_datetime": { + "name": "Due date and time", "description": "The date and time the to-do item is expected to be completed." }, "description": { @@ -58,8 +58,8 @@ "name": "Due date", "description": "The date the to-do item is expected to be completed." }, - "due_date_time": { - "name": "Due date time", + "due_datetime": { + "name": "Due date and time", "description": "The date and time the to-do item is expected to be completed." }, "description": { diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index b8c79210dfb..94b4ad31826 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -16,7 +16,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SETTINGS_URL = "https://todoist.com/app/settings/integrations" +SETTINGS_URL = "https://app.todoist.com/app/settings/integrations/developer" STEP_USER_DATA_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index c0d3ec6e2ce..6231a6878ae 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -1,7 +1,8 @@ """A todo platform for Todoist.""" import asyncio -from typing import cast +import datetime +from typing import Any, cast from homeassistant.components.todo import ( TodoItem, @@ -13,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import TodoistCoordinator @@ -30,6 +32,24 @@ async def async_setup_entry( ) +def _task_api_data(item: TodoItem) -> dict[str, Any]: + """Convert a TodoItem to the set of add or update arguments.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["content"] = summary + if due := item.due: + if isinstance(due, datetime.datetime): + item_data["due"] = { + "date": due.date().isoformat(), + "datetime": due.isoformat(), + } + else: + item_data["due"] = {"date": due.isoformat()} + if description := item.description: + item_data["description"] = description + return item_data + + class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): """A Todoist TodoListEntity.""" @@ -37,6 +57,9 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) def __init__( @@ -62,15 +85,28 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit for task in self.coordinator.data: if task.project_id != self._project_id: continue + if task.parent_id is not None: + # Filter out sub-tasks until they are supported by the UI. + continue if task.is_completed: status = TodoItemStatus.COMPLETED else: status = TodoItemStatus.NEEDS_ACTION + due: datetime.date | datetime.datetime | None = None + if task_due := task.due: + if task_due.datetime: + due = dt_util.as_local( + datetime.datetime.fromisoformat(task_due.datetime) + ) + elif task_due.date: + due = datetime.date.fromisoformat(task_due.date) items.append( TodoItem( summary=task.content, uid=task.id, status=status, + due=due, + description=task.description or None, # Don't use empty string ) ) self._attr_todo_items = items @@ -81,7 +117,7 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if item.status != TodoItemStatus.NEEDS_ACTION: raise ValueError("Only active tasks may be created.") await self.coordinator.api.add_task( - content=item.summary or "", + **_task_api_data(item), project_id=self._project_id, ) await self.coordinator.async_refresh() @@ -89,8 +125,8 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit async def async_update_todo_item(self, item: TodoItem) -> None: """Update a To-do item.""" uid: str = cast(str, item.uid) - if item.summary: - await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if update_data := _task_api_data(item): + await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: if item.status == TodoItemStatus.COMPLETED: await self.coordinator.api.close_task(task_id=uid) diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 750d422cd0d..3b4024c07b4 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your TP-Link device." } }, "pick_device": { diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 6da32cd0c1a..04fa6d162d3 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -8,7 +8,9 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "title": "TP-Link Omada Controller", + "data_description": { + "host": "URL of the management interface of your TP-Link Omada controller." + }, "description": "Enter the connection details for the Omada controller. Cloud controllers aren't supported." }, "site": { diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index c41b24a2647..5c0f05004ba 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -119,8 +119,7 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): if not self._device_control: return - if not preset_mode == ATTR_AUTO: - raise ValueError("Preset must be 'Auto'.") + # Preset must be 'Auto' await self._api(self._device_control.turn_on_auto_mode()) diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 0a9a86bd23a..69a28a567ab 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -7,6 +7,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "security_code": "Security Code" + }, + "data_description": { + "host": "Hostname or IP address of your Trådfri gateway." } } }, diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index d9d28cfe13b..3ac3ce35882 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -6,7 +6,7 @@ import logging from pytrafikverket.trafikverket_camera import TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -42,13 +42,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" + api_key = entry.data[CONF_API_KEY] + web_session = async_get_clientsession(hass) + camera_api = TrafikverketCamera(web_session, api_key) # Change entry unique id from location to camera id if entry.version == 1: location = entry.data[CONF_LOCATION] - api_key = entry.data[CONF_API_KEY] - - web_session = async_get_clientsession(hass) - camera_api = TrafikverketCamera(web_session, api_key) try: camera_info = await camera_api.async_get_camera(location) @@ -60,14 +59,40 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if camera_id := camera_info.camera_id: entry.version = 2 - _LOGGER.debug( - "Migrate Trafikverket Camera config entry unique id to %s", - camera_id, - ) hass.config_entries.async_update_entry( entry, unique_id=f"{DOMAIN}-{camera_id}", ) + _LOGGER.debug( + "Migrated Trafikverket Camera config entry unique id to %s", + camera_id, + ) + else: + _LOGGER.error("Could not migrate the config entry. Camera has no id") + return False + + # Change entry data from location to id + if entry.version == 2: + location = entry.data[CONF_LOCATION] + + try: + camera_info = await camera_api.async_get_camera(location) + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Could not migrate the config entry. No connection to the api" + ) + return False + + if camera_id := camera_info.camera_id: + entry.version = 3 + _LOGGER.debug( + "Migrate Trafikverket Camera config entry unique id to %s", + camera_id, + ) + new_data = entry.data.copy() + new_data.pop(CONF_LOCATION) + new_data[CONF_ID] = camera_id + hass.config_entries.async_update_entry(entry, data=new_data) return True _LOGGER.error("Could not migrate the config entry. Camera has no id") return False diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index e75bc0bfa30..7572855b7d4 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -25,7 +25,7 @@ from .const import CONF_LOCATION, DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Camera integration.""" - VERSION = 2 + VERSION = 3 entry: config_entries.ConfigEntry | None @@ -53,10 +53,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if camera_info: camera_id = camera_info.camera_id - if _location := camera_info.location: - camera_location = _location - else: - camera_location = camera_info.camera_name + camera_location = camera_info.camera_name or "Trafikverket Camera" return (errors, camera_location, camera_id) @@ -76,9 +73,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _, _ = await self.validate_input( - api_key, self.entry.data[CONF_LOCATION] - ) + errors, _, _ = await self.validate_input(api_key, self.entry.data[CONF_ID]) if not errors: self.hass.config_entries.async_update_entry( @@ -121,10 +116,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry( title=camera_location, - data={ - CONF_API_KEY: api_key, - CONF_LOCATION: camera_location, - }, + data={CONF_API_KEY: api_key, CONF_ID: camera_id}, ) return self.async_show_form( diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index eb5a047ca73..8270fecd487 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -15,13 +15,13 @@ from pytrafikverket.exceptions import ( from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_LOCATION, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -48,14 +48,14 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): ) self.session = async_get_clientsession(hass) self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY]) - self._location = entry.data[CONF_LOCATION] + self._id = entry.data[CONF_ID] async def _async_update_data(self) -> CameraData: """Fetch data from Trafikverket.""" camera_data: CameraInfo image: bytes | None = None try: - camera_data = await self._camera_api.async_get_camera(self._location) + camera_data = await self._camera_api.async_get_camera(self._id) except (NoCameraFound, MultipleCamerasFound, UnknownError) as error: raise UpdateFailed from error except InvalidAuthentication as error: diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2d00f35202c..fa6ad8e5382 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -52,23 +52,39 @@ from .const import ( CONF_INVERT, CONF_MAX_SAMPLES, CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, CONF_SAMPLE_DURATION, DOMAIN, ) _LOGGER = logging.getLogger(__name__) -SENSOR_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_INVERT, default=False): cv.boolean, - vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, - vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), - vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, - } + +def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]: + if ( + CONF_MIN_SAMPLES in data + and CONF_MAX_SAMPLES in data + and data[CONF_MAX_SAMPLES] < data[CONF_MIN_SAMPLES] + ): + raise vol.Invalid("min_samples must be smaller than or equal to max_samples") + return data + + +SENSOR_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, + vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int, + } + ), + _validate_min_max, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -96,6 +112,7 @@ async def async_setup_platform( max_samples = device_config[CONF_MAX_SAMPLES] min_gradient = device_config[CONF_MIN_GRADIENT] sample_duration = device_config[CONF_SAMPLE_DURATION] + min_samples = device_config[CONF_MIN_SAMPLES] sensors.append( SensorTrend( @@ -109,8 +126,10 @@ async def async_setup_platform( max_samples, min_gradient, sample_duration, + min_samples, ) ) + if not sensors: _LOGGER.error("No sensors added") return @@ -137,6 +156,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): max_samples: int, min_gradient: float, sample_duration: int, + min_samples: int, ) -> None: """Initialize the sensor.""" self._hass = hass @@ -148,6 +168,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient + self._min_samples = min_samples self.samples: deque = deque(maxlen=max_samples) @property @@ -210,7 +231,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): while self.samples and self.samples[0][0] < cutoff: self.samples.popleft() - if len(self.samples) < 2: + if len(self.samples) < self._min_samples: return # Calculate gradient of linear trend diff --git a/homeassistant/components/trend/const.py b/homeassistant/components/trend/const.py index 6787dc08445..3d82bfcc648 100644 --- a/homeassistant/components/trend/const.py +++ b/homeassistant/components/trend/const.py @@ -12,3 +12,4 @@ CONF_INVERT = "invert" CONF_MAX_SAMPLES = "max_samples" CONF_MIN_GRADIENT = "min_gradient" CONF_SAMPLE_DURATION = "sample_duration" +CONF_MIN_SAMPLES = "min_samples" diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 38715825875..9a44382e851 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -545,7 +545,7 @@ class SpeechManager: self.providers[engine] = provider self.hass.config.components.add( - PLATFORM_FORMAT.format(domain=engine, platform=DOMAIN) + PLATFORM_FORMAT.format(domain=DOMAIN, platform=engine) ) @callback diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 4734c3f22d1..05be2e284e3 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine, Mapping from functools import partial import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config import config_per_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DESCRIPTION, @@ -25,12 +26,12 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_CACHE, @@ -103,8 +104,8 @@ async def async_setup_legacy( # Load service descriptions from tts/services.yaml services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_setup_platform( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index acf9f8bbd2c..19faa76a191 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -338,6 +338,7 @@ class DPCode(StrEnum): TEMP_VALUE_V2 = "temp_value_v2" TEMPER_ALARM = "temper_alarm" # Tamper alarm TIME_TOTAL = "time_total" + TIME_USE = "time_use" # Total seconds of irrigation TOTAL_CLEAN_AREA = "total_clean_area" TOTAL_CLEAN_COUNT = "total_clean_count" TOTAL_CLEAN_TIME = "total_clean_time" @@ -362,6 +363,7 @@ class DPCode(StrEnum): WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level WATERSENSOR_STATE = "watersensor_state" + WEATHER_DELAY = "weather_delay" WET = "wet" # Humidification WINDOW_CHECK = "window_check" WINDOW_STATE = "window_state" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 3cc8c72f555..bc44ddf479c 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -75,6 +75,16 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { icon="mdi:thermometer-lines", ), ), + # Smart Water Timer + "sfkzq": ( + # Irrigation will not be run within this set delay period + SelectEntityDescription( + key=DPCode.WEATHER_DELAY, + translation_key="weather_delay", + icon="mdi:weather-cloudy-clock", + entity_category=EntityCategory.CONFIG, + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 313900fab4e..4bf8808f5f1 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -517,6 +517,18 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Smart Water Timer + "sfkzq": ( + # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) + TuyaSensorEntityDescription( + key=DPCode.TIME_USE, + translation_key="total_watering_time", + icon="mdi:history", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + *BATTERY_SENSORS, + ), # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 9c807419551..e9b13e10a95 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -421,6 +421,19 @@ "4": "Mood 4", "5": "Mood 5" } + }, + "weather_delay": { + "name": "Weather delay", + "state": { + "cancel": "Cancel", + "24h": "24h", + "48h": "48h", + "72h": "72h", + "96h": "96h", + "120h": "120h", + "144h": "144h", + "168h": "168h" + } } }, "sensor": { @@ -556,6 +569,9 @@ "water_level": { "name": "Water level" }, + "total_watering_time": { + "name": "Total watering time" + }, "filter_utilization": { "name": "Filter utilization" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index a48d797555c..ba304b4069e 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -430,6 +430,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Water Timer + "sfkzq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + icon="mdi:sprinkler-variant", + ), + ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json index 9b4c8ebd778..88bc67abbbd 100644 --- a/homeassistant/components/twinkly/strings.json +++ b/homeassistant/components/twinkly/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Twinkly device." } }, "discovery_confirm": { diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index a678517eca9..e1867b2df2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -8,6 +8,7 @@ Configuration of options through options flow. from __future__ import annotations from collections.abc import Mapping +import operator import socket from types import MappingProxyType from typing import Any @@ -309,6 +310,11 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): client.mac: f"{client.name or client.hostname} ({client.mac})" for client in self.controller.api.clients.values() } + clients |= { + mac: f"Unknown ({mac})" + for mac in self.options.get(CONF_CLIENT_SOURCE, []) + if mac not in clients + } return self.async_show_form( step_id="configure_entity_sources", @@ -317,7 +323,9 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_CLIENT_SOURCE, default=self.options.get(CONF_CLIENT_SOURCE, []), - ): cv.multi_select(clients), + ): cv.multi_select( + dict(sorted(clients.items(), key=operator.itemgetter(1))) + ), } ), last_step=False, diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6bd8b9db426..035cf66a983 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -5,7 +5,7 @@ import asyncio from datetime import datetime, timedelta import ssl from types import MappingProxyType -from typing import Any +from typing import Any, Literal from aiohttp import CookieJar import aiounifi @@ -458,7 +458,7 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context: ssl.SSLContext | bool = False + ssl_context: ssl.SSLContext | Literal[False] = False if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 52ed8ec3101..7d4717d3fff 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==66"], + "requirements": ["aiounifi==67"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3d0ffa1896e..4d5cf49b5c9 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -151,6 +151,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:upload", @@ -171,6 +172,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", device_class=SensorDeviceClass.DATA_RATE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:download", @@ -231,6 +233,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="WLAN clients", entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 9c609ca8c07..ba426c2f08a 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -11,6 +11,9 @@ "port": "[%key:common::config_flow::data::port%]", "site": "Site ID", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Network." } } }, diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ee6f6d05548..045538aa2d1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.21.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 73ac6e08c17..a345a504c42 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -11,6 +11,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "Hostname or IP address of your UniFi Protect device." } }, "reauth_confirm": { diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a0cf3aae03a..bf19fe5188e 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your V2C Trydan EVSE." } } }, diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 2f420096c74..e58c3ebd88d 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -11,11 +11,7 @@ from vallox_websocket_api import ( ValloxInvalidInputException, ) -from homeassistant.components.fan import ( - FanEntity, - FanEntityFeature, - NotValidPresetModeError, -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -200,12 +196,6 @@ class ValloxFanEntity(ValloxEntity, FanEntity): Returns true if the mode has been changed, false otherwise. """ - try: - self._valid_preset_mode_or_raise(preset_mode) - - except NotValidPresetModeError as err: - raise ValueError(f"Not valid preset mode: {preset_mode}") from err - if preset_mode == self.preset_mode: return False diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index acc6a31f158..e3ade9a55c4 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vallox device." } } }, diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json index a844adc2156..92dfac211fb 100644 --- a/homeassistant/components/venstar/strings.json +++ b/homeassistant/components/venstar/strings.json @@ -2,13 +2,16 @@ "config": { "step": { "user": { - "title": "Connect to the Venstar Thermostat", + "description": "Connect to the Venstar thermostat", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "pin": "[%key:common::config_flow::data::pin%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "host": "Hostname or IP address of your Venstar thermostat." } } }, diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index a0e5b9da52e..b2fd090e781 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -18,9 +18,21 @@ DEV_TYPE_TO_HA = { "ESWL01": "switch", "ESWL03": "switch", "ESO15-TB": "outlet", + "LV-PUR131S": "fan", + "Core200S": "fan", + "Core300S": "fan", + "Core400S": "fan", + "Core600S": "fan", + "Vital200S": "fan", + "Vital100S": "fan", + "ESD16": "walldimmer", + "ESWD16": "walldimmer", + "ESL100": "bulb-dimmable", + "ESL100CW": "bulb-tunable-white", } SKU_TO_BASE_DEVICE = { + # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 326e7daf12c..f0d4d02a9a3 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,26 +11,16 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .common import VeSyncDevice -from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "LV-PUR131S": "fan", - "Core200S": "fan", - "Core300S": "fan", - "Core400S": "fan", - "Core600S": "fan", - "Vital200S": "fan", - "Vital100S": "fan", -} - FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index e6cc979e808..040e9d5696d 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,17 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "ESD16": "walldimmer", - "ESWD16": "walldimmer", - "ESL100": "bulb-dimmable", - "ESL100CW": "bulb-tunable-white", -} - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index fb892acfd4f..ff3f56dd184 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,7 +1,7 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey"], + "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 76de3a8a7ac..2e3284c37c4 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -41,7 +41,7 @@ _TOKEN_FILENAME = "vicare_token.save" class ViCareRequiredKeysMixin: """Mixin for required keys.""" - value_getter: Callable[[Device], bool] + value_getter: Callable[[Device], Any] @dataclass() diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index f92f24d01ce..95a4bcdc9f0 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,12 +1,16 @@ """Viessmann ViCare sensor device.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -37,6 +41,8 @@ class ViCareBinarySensorEntityDescription( ): """Describes ViCare binary sensor entity.""" + value_getter: Callable[[PyViCareDevice], bool] + CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( @@ -104,39 +110,67 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareDevice, +def _build_entities( + device: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareBinarySensorEntityDescription, -): - """Create a ViCare binary sensor entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareBinarySensor( - vicare_api, - device_config, - entity_description, +) -> list[ViCareBinarySensor]: + """Create ViCare binary sensor entities for a device.""" + + entities: list[ViCareBinarySensor] = _build_entities_for_device( + device, device_config + ) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS ) - return None + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities -async def _entities_from_descriptions( - hass: HomeAssistant, - entities: list[ViCareBinarySensor], - sensor_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], - iterables, - config_entry: ConfigEntry, -) -> None: - """Create entities from descriptions and list of burners/circuits.""" - for description in sensor_descriptions: - for current in iterables: - entity = await hass.async_add_executor_job( - _build_entity, - current, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, - ) - if entity: - entities.append(entity) +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareBinarySensor]: + """Create device specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], +) -> list[ViCareBinarySensor]: + """Create component specific ViCare binary sensor entities.""" + + return [ + ViCareBinarySensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] async def async_setup_entry( @@ -146,36 +180,16 @@ async def async_setup_entry( ) -> None: """Create the ViCare binary sensor devices.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity: - entities.append(entity) - - circuits = await hass.async_add_executor_job(get_circuits, api) - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) - burners = await hass.async_add_executor_job(get_burners, api) - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, burners, config_entry - ) - - compressors = await hass.async_add_executor_job(get_compressors, api) - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, compressors, config_entry - ) - - async_add_entities(entities) - class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): """Representation of a ViCare sensor.""" diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index d0e50b5f772..374d98b3397 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -47,19 +47,21 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ) -def _build_entity( - vicare_api: PyViCareDevice, +def _build_entities( + api: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareButtonEntityDescription, -): - """Create a ViCare button entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareButton( - vicare_api, +) -> list[ViCareButton]: + """Create ViCare button entities for a device.""" + + return [ + ViCareButton( + api, device_config, - entity_description, + description, ) - return None + for description in BUTTON_DESCRIPTIONS + if is_supported(description.key, description, api) + ] async def async_setup_entry( @@ -69,20 +71,15 @@ async def async_setup_entry( ) -> None: """Create the ViCare button entities.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - entities = [] - - for description in BUTTON_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - description, + device_config, ) - if entity: - entities.append(entity) - - async_add_entities(entities) + ) class ViCareButton(ViCareEntity, ButtonEntity): diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index c1e04e1d1b2..c14f940ffe6 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -95,25 +95,30 @@ HA_TO_VICARE_PRESET_HEATING = { } +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareClimate]: + """Create ViCare climate entities for a device.""" + return [ + ViCareClimate( + api, + circuit, + device_config, + "heating", + ) + for circuit in get_circuits(api) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - - for circuit in circuits: - entity = ViCareClimate( - api, - circuit, - device_config, - "heating", - ) - entities.append(entity) platform = entity_platform.async_get_current_platform() @@ -123,7 +128,13 @@ async def async_setup_entry( "set_vicare_mode", ) - async_add_entities(entities) + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) class ViCareClimate(ViCareEntity, ClimateEntity): diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 45abd0a5cda..965b5a619fc 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -19,7 +19,11 @@ from PyViCare.PyViCareUtils import ( ) from requests.exceptions import ConnectionError as RequestConnectionError -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -37,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare number entity.""" + value_getter: Callable[[PyViCareDevice], float] value_setter: Callable[[PyViCareDevice, float], Any] | None = None min_value_getter: Callable[[PyViCareDevice], float | None] | None = None max_value_getter: Callable[[PyViCareDevice], float | None] | None = None @@ -49,6 +54,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( translation_key="heating_curve_shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getHeatingCurveShift(), value_setter=lambda api, shift: ( @@ -77,22 +83,61 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( native_max_value=3.5, native_step=0.1, ), + ViCareNumberEntityDescription( + key="normal_temperature", + translation_key="normal_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("normal"), + value_setter=lambda api, value: api.setProgramTemperature("normal", value), + min_value_getter=lambda api: api.getProgramMinTemperature("normal"), + max_value_getter=lambda api: api.getProgramMaxTemperature("normal"), + stepping_getter=lambda api: api.getProgramStepping("normal"), + ), + ViCareNumberEntityDescription( + key="reduced_temperature", + translation_key="reduced_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("reduced"), + value_setter=lambda api, value: api.setProgramTemperature("reduced", value), + min_value_getter=lambda api: api.getProgramMinTemperature("reduced"), + max_value_getter=lambda api: api.getProgramMaxTemperature("reduced"), + stepping_getter=lambda api: api.getProgramStepping("reduced"), + ), + ViCareNumberEntityDescription( + key="comfort_temperature", + translation_key="comfort_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("comfort"), + value_setter=lambda api, value: api.setProgramTemperature("comfort", value), + min_value_getter=lambda api: api.getProgramMinTemperature("comfort"), + max_value_getter=lambda api: api.getProgramMaxTemperature("comfort"), + stepping_getter=lambda api: api.getProgramStepping("comfort"), + ), ) -def _build_entity( - vicare_api: PyViCareHeatingDeviceComponent, +def _build_entities( + api: PyViCareDevice, device_config: PyViCareDeviceConfig, - entity_description: ViCareNumberEntityDescription, -) -> ViCareNumber | None: - """Create a ViCare number entity.""" - if is_supported(entity_description.key, entity_description, vicare_api): - return ViCareNumber( - vicare_api, +) -> list[ViCareNumber]: + """Create ViCare number entities for a component.""" + + return [ + ViCareNumber( + circuit, device_config, - entity_description, + description, ) - return None + for circuit in get_circuits(api) + for description in CIRCUIT_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, circuit) + ] async def async_setup_entry( @@ -101,23 +146,16 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare number devices.""" - entities: list[ViCareNumber] = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - for circuit in circuits: - for description in CIRCUIT_ENTITY_DESCRIPTIONS: - entity = await hass.async_add_executor_job( - _build_entity, - circuit, - device_config, - description, - ) - if entity: - entities.append(entity) - - async_add_entities(entities) + async_add_entities( + await hass.async_add_executor_job( + _build_entities, + api, + device_config, + ) + ) class ViCareNumber(ViCareEntity, NumberEntity): @@ -144,7 +182,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): """Set new value.""" if self.entity_description.value_setter: self.entity_description.value_setter(self._api, value) - self.async_write_ha_state() + self.schedule_update_ha_state() def update(self) -> None: """Update state of number.""" @@ -153,6 +191,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): self._attr_native_value = self.entity_description.value_getter( self._api ) + if min_value := _get_value( self.entity_description.min_value_getter, self._api ): diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 444f5347f41..ec93488d172 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -6,8 +6,11 @@ from contextlib import suppress from dataclasses import dataclass import logging -from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceWithComponent, +) from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -59,7 +62,7 @@ VICARE_UNIT_TO_DEVICE_CLASS = { class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare sensor entity.""" - unit_getter: Callable[[Device], str | None] | None = None + unit_getter: Callable[[PyViCareDevice], str | None] | None = None GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( @@ -692,45 +695,86 @@ async def _entities_from_descriptions( entities.append(entity) +def _build_entities( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create ViCare sensor entities for a device.""" + + entities: list[ViCareSensor] = _build_entities_for_device(device, device_config) + entities.extend( + _build_entities_for_component( + get_circuits(device), device_config, CIRCUIT_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_burners(device), device_config, BURNER_SENSORS + ) + ) + entities.extend( + _build_entities_for_component( + get_compressors(device), device_config, COMPRESSOR_SENSORS + ) + ) + return entities + + +def _build_entities_for_device( + device: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareSensor]: + """Create device specific ViCare sensor entities.""" + + return [ + ViCareSensor( + device, + device_config, + description, + ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device) + ] + + +def _build_entities_for_component( + components: list[PyViCareHeatingDeviceWithComponent], + device_config: PyViCareDeviceConfig, + entity_descriptions: tuple[ViCareSensorEntityDescription, ...], +) -> list[ViCareSensor]: + """Create component specific ViCare sensor entities.""" + + return [ + ViCareSensor( + component, + device_config, + description, + ) + for component in components + for description in entity_descriptions + if is_supported(description.key, description, component) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - api: Device = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] + api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][ VICARE_DEVICE_CONFIG ] - entities = [] - for description in GLOBAL_SENSORS: - entity = await hass.async_add_executor_job( - _build_entity, + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, device_config, - description, ) - if entity: - entities.append(entity) - - circuits = await hass.async_add_executor_job(get_circuits, api) - await _entities_from_descriptions( - hass, entities, CIRCUIT_SENSORS, circuits, config_entry ) - burners = await hass.async_add_executor_job(get_burners, api) - await _entities_from_descriptions( - hass, entities, BURNER_SENSORS, burners, config_entry - ) - - compressors = await hass.async_add_executor_job(get_compressors, api) - await _entities_from_descriptions( - hass, entities, COMPRESSOR_SENSORS, compressors, config_entry - ) - - async_add_entities(entities) - class ViCareSensor(ViCareEntity, SensorEntity): """Representation of a ViCare sensor.""" diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 0f3d729f670..97572e9d01c 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -80,9 +80,6 @@ }, "comfort_temperature": { "name": "Comfort temperature" - }, - "eco_temperature": { - "name": "Eco temperature" } }, "sensor": { @@ -307,8 +304,8 @@ } }, "water_heater": { - "water": { - "name": "Water" + "domestic_hot_water": { + "name": "Domestic hot water" } } }, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 9b154da2bc2..66a90ca065b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,4 +1,6 @@ """Viessmann ViCare water_heater device.""" +from __future__ import annotations + from contextlib import suppress import logging from typing import Any @@ -58,27 +60,38 @@ HA_TO_VICARE_HVAC_DHW = { } +def _build_entities( + api: PyViCareDevice, + device_config: PyViCareDeviceConfig, +) -> list[ViCareWater]: + """Create ViCare domestic hot water entities for a device.""" + return [ + ViCareWater( + api, + circuit, + device_config, + "domestic_hot_water", + ) + for circuit in get_circuits(api) + ] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the ViCare climate platform.""" - entities = [] + """Set up the ViCare water heater platform.""" api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] - circuits = await hass.async_add_executor_job(get_circuits, api) - for circuit in circuits: - entity = ViCareWater( + async_add_entities( + await hass.async_add_executor_job( + _build_entities, api, - circuit, device_config, - "water", ) - entities.append(entity) - - async_add_entities(entities) + ) class ViCareWater(ViCareEntity, WaterHeaterEntity): diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index d559e3a6716..f2c4c38780b 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Vilfo router." } } }, diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 0ff64eeda53..6091cd72f3f 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -2,13 +2,15 @@ "config": { "step": { "user": { - "title": "VIZIO SmartCast Device", "description": "An access token is only needed for TVs. If you are configuring a TV and do not have an access token yet, leave it blank to go through a pairing process.", "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "device_class": "Device Type", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your VIZIO SmartCast device." } }, "pair_tv": { diff --git a/homeassistant/components/vlc_telnet/strings.json b/homeassistant/components/vlc_telnet/strings.json index 3a22bd06602..c0cacc734d3 100644 --- a/homeassistant/components/vlc_telnet/strings.json +++ b/homeassistant/components/vlc_telnet/strings.json @@ -14,6 +14,9 @@ "port": "[%key:common::config_flow::data::port%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your VLC media player." } }, "hassio_confirm": { diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index a2cddcf9a65..ff51f009f3c 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -97,6 +97,9 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): try: try: await self.api.login() + raw_data_devices = await self.api.get_devices_data() + data_sensors = await self.api.get_sensor_data() + await self.api.logout() except exceptions.CannotAuthenticate as err: raise ConfigEntryAuthFailed from err except ( @@ -117,10 +120,8 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): dev_info, utc_point_in_time ), ) - for dev_info in (await self.api.get_devices_data()).values() + for dev_info in (raw_data_devices).values() } - data_sensors = await self.api.get_sensor_data() - await self.api.logout() return UpdateCoordinatorDataType(data_devices, data_sensors) @property diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 2a1814c83d0..20ea4db057e 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.2"] + "requirements": ["aiovodafone==0.4.3"] } diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index aaaa27a3614..fab266ac47f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -13,6 +13,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Vodafone Station." } } }, diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 120f2d9559b..11f70c631f1 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -5,10 +5,12 @@ import asyncio from collections import deque from collections.abc import AsyncIterable, MutableSequence, Sequence from functools import partial +import io import logging from pathlib import Path import time from typing import TYPE_CHECKING +import wave from voip_utils import ( CallInfo, @@ -285,7 +287,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): ), conversation_id=self._conversation_id, device_id=self.voip_device.device_id, - tts_audio_output="raw", + tts_audio_output="wav", ) if self._pipeline_error: @@ -387,11 +389,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type == PipelineEventType.TTS_END: # Send TTS audio to caller over RTP - media_id = event.data["tts_output"]["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), - "voip_pipeline_tts", - ) + tts_output = event.data["tts_output"] + if tts_output: + media_id = tts_output["media_id"] + self.hass.async_create_background_task( + self._send_tts(media_id), + "voip_pipeline_tts", + ) + else: + # Empty TTS response + self._tts_done.set() elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS self._pipeline_error = True @@ -402,11 +409,32 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): if self.transport is None: return - _extension, audio_bytes = await tts.async_get_media_source_audio( + extension, data = await tts.async_get_media_source_audio( self.hass, media_id, ) + if extension != "wav": + raise ValueError(f"Only WAV audio can be streamed, got {extension}") + + with io.BytesIO(data) as wav_io: + with wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + + if ( + (sample_rate != 16000) + or (sample_width != 2) + or (sample_channels != 1) + ): + raise ValueError( + "Expected rate/width/channels as 16000/2/1," + " got {sample_rate}/{sample_width}/{sample_channels}}" + ) + + audio_bytes = wav_file.readframes(wav_file.getnframes()) + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) # Time out 1 second after TTS audio should be finished @@ -414,7 +442,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_seconds = tts_samples / RATE async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): - # Assume TTS audio is 16Khz 16-bit mono + # TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) except asyncio.TimeoutError as err: _LOGGER.warning("TTS timeout") diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json index ba283a3af37..32552ad7386 100644 --- a/homeassistant/components/volumio/strings.json +++ b/homeassistant/components/volumio/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Volumio media player." } }, "discovery_confirm": { diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py new file mode 100644 index 00000000000..bd6571a390e --- /dev/null +++ b/homeassistant/components/weather/significant_change.py @@ -0,0 +1,175 @@ +"""Helper to test significant Weather state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import check_absolute_change + +from .const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, +} + +VALID_CARDINAL_DIRECTIONS: list[str] = [ + "n", + "nne", + "ne", + "ene", + "e", + "ese", + "se", + "sse", + "s", + "ssw", + "sw", + "wsw", + "w", + "wnw", + "nw", + "nnw", +] + + +def _check_valid_float(value: str | int | float) -> bool: + """Check if given value is a valid float.""" + try: + float(value) + except ValueError: + return False + return True + + +def _cardinal_to_degrees(value: str | int | float | None) -> int | float | None: + """Translate a cardinal direction into azimuth angle (degrees).""" + if not isinstance(value, str): + return value + + try: + return float(360 / 16 * VALID_CARDINAL_DIRECTIONS.index(value.lower())) + except ValueError: + return None + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + # state changes are always significant + if old_state != new_state: + return True + + old_attrs_s = set(old_attrs.items()) + new_attrs_s = set(new_attrs.items()) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name not in SIGNIFICANT_ATTRIBUTES: + continue + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + absolute_change: float | None = None + if attr_name == ATTR_WEATHER_WIND_BEARING: + old_attr_value = _cardinal_to_degrees(old_attr_value) + new_attr_value = _cardinal_to_degrees(new_attr_value) + + if new_attr_value is None or not _check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not _check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if attr_name in ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_TEMPERATURE, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_TEMPERATURE_UNIT) + ) is not None and unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_WIND_SPEED_UNIT) + ) is None or unit in ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, # 1km/h = 0.62mi/s + UnitOfSpeed.FEET_PER_SECOND, # 1km/h = 0.91ft/s + ): + absolute_change = 1.0 + elif unit == UnitOfSpeed.METERS_PER_SECOND: # 1km/h = 0.277m/s + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_CLOUD_COVERAGE, # range 0-100% + ATTR_WEATHER_HUMIDITY, # range 0-100% + ATTR_WEATHER_OZONE, # range ~20-100ppm + ATTR_WEATHER_VISIBILITY, # range 0-240km (150mi) + ATTR_WEATHER_WIND_BEARING, # range 0-359° + ): + absolute_change = 1.0 + + if attr_name == ATTR_WEATHER_UV_INDEX: # range 1-11 + absolute_change = 0.1 + + if attr_name == ATTR_WEATHER_PRESSURE: # local variation of around 100 hpa + if (unit := new_attrs.get(ATTR_WEATHER_PRESSURE_UNIT)) is None or unit in ( + UnitOfPressure.HPA, + UnitOfPressure.MBAR, # 1hPa = 1mbar + UnitOfPressure.MMHG, # 1hPa = 0.75mmHg + ): + absolute_change = 1.0 + elif unit == UnitOfPressure.INHG: # 1hPa = 0.03inHg + absolute_change = 0.05 + + # check for significant attribute value change + if absolute_change is not None: + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 8f7a98abe04..d075ee34a05 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,10 +2,12 @@ "config": { "step": { "user": { - "title": "WeatherFlow discovery", "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Tempest WeatherFlow device." } } }, diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index a918ce0f850..824c85781ea 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -37,7 +37,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=timedelta(minutes=5), ) async def update_supported_data_sets(self): diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index d28a6ff3315..a6dd40d5993 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.4"] + "requirements": ["apple_weatherkit==1.1.2"] } diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index a5e7b73e59e..1d045d48ba5 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,11 +3,13 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "title": "Connect to webOS TV", "description": "Turn on TV, fill the following fields click submit", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "host": "Hostname or IP address of your webOS TV." } }, "pairing": { diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 9c2645aec57..f7086cc81db 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -17,6 +17,7 @@ from .const import ( # noqa: F401 ERR_INVALID_FORMAT, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, + ERR_SERVICE_VALIDATION_ERROR, ERR_TEMPLATE_ERROR, ERR_TIMEOUT, ERR_UNAUTHORIZED, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 18688914e8b..cb90b46e182 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -778,7 +778,22 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - script_result = await script_obj.async_run(msg.get("variables"), context=context) + try: + script_result = await script_obj.async_run( + msg.get("variables"), context=context + ) + except ServiceValidationError as err: + connection.logger.error(err) + connection.logger.debug("", exc_info=err) + connection.send_error( + msg["id"], + const.ERR_SERVICE_VALIDATION_ERROR, + str(err), + translation_domain=err.translation_domain, + translation_key=err.translation_key, + translation_placeholders=err.translation_placeholders, + ) + return connection.send_result( msg["id"], { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 4581b3be773..25b6c90d1ba 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -255,7 +255,10 @@ class ActiveConnection: log_handler = self.logger.error code = const.ERR_UNKNOWN_ERROR - err_message = None + err_message: str | None = None + translation_domain: str | None = None + translation_key: str | None = None + translation_placeholders: dict[str, Any] | None = None if isinstance(err, Unauthorized): code = const.ERR_UNAUTHORIZED @@ -268,6 +271,10 @@ class ActiveConnection: err_message = "Timeout" elif isinstance(err, HomeAssistantError): err_message = str(err) + code = const.ERR_HOME_ASSISTANT_ERROR + translation_domain = err.translation_domain + translation_key = err.translation_key + translation_placeholders = err.translation_placeholders # This if-check matches all other errors but also matches errors which # result in an empty message. In that case we will also log the stack @@ -276,7 +283,16 @@ class ActiveConnection: err_message = "Unknown error" log_handler = self.logger.exception - self.send_message(messages.error_message(msg["id"], code, err_message)) + self.send_message( + messages.error_message( + msg["id"], + code, + err_message, + translation_domain=translation_domain, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + ) if code: err_message += f" ({code})" diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index e1c8655c196..39abdba6e82 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import async_wemo_dispatcher_connect from .const import SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 06fbfa3621e..cfbdb6bdc92 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -5,6 +5,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException +from wirelesstagpy.sensortag import SensorTag from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -17,6 +18,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity @@ -126,6 +128,22 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) +def async_migrate_unique_id( + hass: HomeAssistant, tag: SensorTag, domain: str, key: str +) -> None: + """Migrate old unique id to new one with use of tag's uuid.""" + registry = er.async_get(hass) + new_unique_id = f"{tag.uuid}_{key}" + + if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): + return + + old_unique_id = f"{tag.tag_id}_{key}" + if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): + _LOGGER.debug("Updating unique id for %s %s", key, entity_id) + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 711c2987735..64a1097bcab 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON +from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,6 +15,7 @@ from . import ( DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, WirelessTagBaseSensor, + async_migrate_unique_id, ) # On means in range, Off means out of range @@ -72,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the platform for a WirelessTags.""" @@ -87,9 +88,10 @@ def setup_platform( allowed_sensor_types = tag.supported_binary_events_types for sensor_type in config[CONF_MONITORED_CONDITIONS]: if sensor_type in allowed_sensor_types: + async_migrate_unique_id(hass, tag, Platform.BINARY_SENSOR, sensor_type) sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -100,7 +102,7 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): super().__init__(api, tag) self._sensor_type = sensor_type self._name = f"{self._tag.name} {self.event.human_readable_name}" - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index fd9a7898f92..8ae20031723 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -12,14 +12,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + SIGNAL_TAG_UPDATE, + WirelessTagBaseSensor, + async_migrate_unique_id, +) _LOGGER = logging.getLogger(__name__) @@ -68,10 +73,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" @@ -83,9 +88,10 @@ def setup_platform( if key not in tag.allowed_sensor_types: continue description = SENSOR_TYPES[key] + async_migrate_unique_id(hass, tag, Platform.SENSOR, description.key) sensors.append(WirelessTagSensor(platform, tag, description)) - add_entities(sensors, True) + async_add_entities(sensors, True) class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): @@ -100,7 +106,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): self._sensor_type = description.key self.entity_description = description self._name = self._tag.name - self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" + self._attr_unique_id = f"{self._uuid}_{self._sensor_type}" # I want to see entity_id as: # sensor.wirelesstag_bedroom_temperature diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index df0f72aca18..7f4008623b1 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -10,13 +10,17 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor +from . import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WirelessTagBaseSensor, + async_migrate_unique_id, +) SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -52,10 +56,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switches for a Wireless Sensor Tags.""" @@ -63,15 +67,17 @@ def setup_platform( tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] - entities = [ - WirelessTagSwitch(platform, tag, description) - for tag in tags.values() - for description in SWITCH_TYPES - if description.key in monitored_conditions - and description.key in tag.allowed_monitoring_types - ] + entities = [] + for tag in tags.values(): + for description in SWITCH_TYPES: + if ( + description.key in monitored_conditions + and description.key in tag.allowed_monitoring_types + ): + async_migrate_unique_id(hass, tag, Platform.SWITCH, description.key) + entities.append(WirelessTagSwitch(platform, tag, description)) - add_entities(entities, True) + async_add_entities(entities, True) class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): @@ -82,7 +88,7 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): super().__init__(api, tag) self.entity_description = description self._name = f"{self._tag.name} {description.name}" - self._attr_unique_id = f"{self.tag_id}_{description.key}" + self._attr_unique_id = f"{self._uuid}_{description.key}" def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 19572682d1a..132f00936f3 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -66,7 +66,7 @@ def get_event_name(category: WorkoutCategory) -> str: class WithingsWorkoutCalendarEntity( - CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator] + WithingsEntity[WithingsWorkoutDataUpdateCoordinator], CalendarEntity ): """A calendar entity.""" diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 61b9cc450fe..eff6dfab572 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -6,6 +6,9 @@ "description": "Set up your WLED to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your WLED device." } }, "zeroconf_confirm": { diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 9cc96db7a57..2d1030c6b92 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -209,21 +209,26 @@ class IsWorkdaySensor(BinarySensorEntity): async def async_update(self) -> None: """Get date and look whether it is a holiday.""" + self._attr_is_on = self.date_is_workday(dt_util.now()) + + async def check_date(self, check_date: date) -> ServiceResponse: + """Service to check if date is workday or not.""" + return {"workday": self.date_is_workday(check_date)} + + def date_is_workday(self, check_date: date) -> bool: + """Check if date is workday.""" # Default is no workday - self._attr_is_on = False + is_workday = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = dt_util.now() + timedelta(days=self._days_offset) + adjusted_date = check_date + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): - self._attr_is_on = True + is_workday = True if self.is_exclude(day_of_week, adjusted_date): - self._attr_is_on = False + is_workday = False - async def check_date(self, check_date: date) -> ServiceResponse: - """Check if date is workday or not.""" - holiday_date = check_date in self._obj_holidays - return {"workday": not holiday_date} + return is_workday diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c7c993e70d0..dd2df87234f 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.36"] + "requirements": ["holidays==0.37"] } diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 33064d21097..88e490d6dc9 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,17 +4,31 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService +from .devices import SatelliteDevice +from .models import DomainDataItem +from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) +SATELLITE_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, + Platform.NUMBER, +] + __all__ = [ "ATTR_SPEAKER", "DOMAIN", + "async_setup_entry", + "async_unload_entry", ] @@ -25,24 +39,72 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if service is None: raise ConfigEntryNotReady("Unable to connect") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service + item = DomainDataItem(service=service) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item - await hass.config_entries.async_forward_entry_setups( - entry, - service.platforms, - ) + await hass.config_entries.async_forward_entry_setups(entry, service.platforms) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + if (satellite_info := service.info.satellite) is not None: + # Create satellite device, etc. + item.satellite = _make_satellite(hass, entry, service) + + # Set up satellite sensors, switches, etc. + await hass.config_entries.async_forward_entry_setups(entry, SATELLITE_PLATFORMS) + + # Start satellite communication + entry.async_create_background_task( + hass, + item.satellite.run(), + f"Satellite {satellite_info.name}", + ) + + entry.async_on_unload(item.satellite.stop) return True +def _make_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService +) -> WyomingSatellite: + """Create Wyoming satellite/device from config entry and Wyoming service.""" + satellite_info = service.info.satellite + assert satellite_info is not None + + dev_reg = dr.async_get(hass) + + # Use config entry id since only one satellite per entry is supported + satellite_id = config_entry.entry_id + + device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, satellite_id)}, + name=satellite_info.name, + suggested_area=satellite_info.area, + ) + + satellite_device = SatelliteDevice( + satellite_id=satellite_id, + device_id=device.id, + ) + + return WyomingSatellite(hass, service, satellite_device) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Wyoming.""" - service: WyomingService = hass.data[DOMAIN][entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, - service.platforms, - ) + platforms = list(item.service.platforms) + if item.satellite is not None: + platforms += SATELLITE_PLATFORMS + + unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py new file mode 100644 index 00000000000..4f2c0bb170a --- /dev/null +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -0,0 +1,55 @@ +"""Binary sensor for Wyoming.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensor entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteAssistInProgress(item.satellite.device)]) + + +class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntity): + """Entity to represent Assist is in progress for satellite.""" + + entity_description = BinarySensorEntityDescription( + key="assist_in_progress", + translation_key="assist_in_progress", + ) + _attr_is_on = False + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + self._device.set_is_active_listener(self._is_active_changed) + + @callback + def _is_active_changed(self) -> None: + """Call when active state changed.""" + self._attr_is_on = self._device.is_active + self.async_write_ha_state() diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index f6b8ed73890..b766fc80c89 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -1,19 +1,22 @@ """Config flow for Wyoming integration.""" from __future__ import annotations +import logging from typing import Any from urllib.parse import urlparse import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components import hassio, zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .data import WyomingService +_LOGGER = logging.getLogger() + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - _hassio_discovery: HassioServiceInfo + _hassio_discovery: hassio.HassioServiceInfo + _service: WyomingService | None = None + _name: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -50,27 +55,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (speech-to-text) - asr_installed = [asr for asr in service.info.asr if asr.installed] + if name := service.get_name(): + return self.async_create_entry(title=name, data=user_input) - # TTS = text-to-speech - tts_installed = [tts for tts in service.info.tts if tts.installed] + return self.async_abort(reason="no_services") - # wake-word-detection - wake_installed = [wake for wake in service.info.wake if wake.installed] - - if asr_installed: - name = asr_installed[0].name - elif tts_installed: - name = tts_installed[0].name - elif wake_installed: - name = wake_installed[0].name - else: - return self.async_abort(reason="no_services") - - return self.async_create_entry(title=name, data=user_input) - - async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: + async def async_step_hassio( + self, discovery_info: hassio.HassioServiceInfo + ) -> FlowResult: """Handle Supervisor add-on discovery.""" await self.async_set_unique_id(discovery_info.uuid) self._abort_if_unique_id_configured() @@ -93,11 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if ( - not any(asr for asr in service.info.asr if asr.installed) - and not any(tts for tts in service.info.tts if tts.installed) - and not any(wake for wake in service.info.wake if wake.installed) - ): + if not service.has_services(): return self.async_abort(reason="no_services") return self.async_create_entry( @@ -112,3 +100,52 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"addon": self._hassio_discovery.name}, errors=errors, ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Discovery info: %s", discovery_info) + if discovery_info.port is None: + return self.async_abort(reason="no_port") + + service = await WyomingService.create(discovery_info.host, discovery_info.port) + if (service is None) or (not (name := service.get_name())): + # No supported services + return self.async_abort(reason="no_services") + + self._name = name + + # Use zeroconf name + service name as unique id. + # The satellite will use its own MAC as the zeroconf name by default. + unique_id = f"{discovery_info.name}_{self._name}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self.context[CONF_NAME] = self._name + self.context["title_placeholders"] = {"name": self._name} + + self._service = service + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + assert self._service is not None + assert self._name is not None + + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self._name}, + errors={}, + ) + + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._service.host, + CONF_PORT: self._service.port, + }, + ) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index 64b92eb8471..ea58181a707 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info +from wyoming.info import Describe, Info, Satellite from homeassistant.const import Platform @@ -32,6 +32,43 @@ class WyomingService: platforms.append(Platform.WAKE_WORD) self.platforms = platforms + def has_services(self) -> bool: + """Return True if services are installed that Home Assistant can use.""" + return ( + any(asr for asr in self.info.asr if asr.installed) + or any(tts for tts in self.info.tts if tts.installed) + or any(wake for wake in self.info.wake if wake.installed) + or ((self.info.satellite is not None) and self.info.satellite.installed) + ) + + def get_name(self) -> str | None: + """Return name of first installed usable service.""" + # ASR = automated speech recognition (speech-to-text) + asr_installed = [asr for asr in self.info.asr if asr.installed] + if asr_installed: + return asr_installed[0].name + + # TTS = text-to-speech + tts_installed = [tts for tts in self.info.tts if tts.installed] + if tts_installed: + return tts_installed[0].name + + # wake-word-detection + wake_installed = [wake for wake in self.info.wake if wake.installed] + if wake_installed: + return wake_installed[0].name + + # satellite + satellite_installed: Satellite | None = None + + if (self.info.satellite is not None) and self.info.satellite.installed: + satellite_installed = self.info.satellite + + if satellite_installed: + return satellite_installed.name + + return None + @classmethod async def create(cls, host: str, port: int) -> WyomingService | None: """Create a Wyoming service.""" diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py new file mode 100644 index 00000000000..bd7252bcf6b --- /dev/null +++ b/homeassistant/components/wyoming/devices.py @@ -0,0 +1,141 @@ +"""Class to manage satellite devices.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + + +@dataclass +class SatelliteDevice: + """Class to store device.""" + + satellite_id: str + device_id: str + is_active: bool = False + is_enabled: bool = True + pipeline_name: str | None = None + noise_suppression_level: int = 0 + auto_gain: int = 0 + volume_multiplier: float = 1.0 + + _is_active_listener: Callable[[], None] | None = None + _is_enabled_listener: Callable[[], None] | None = None + _pipeline_listener: Callable[[], None] | None = None + _audio_settings_listener: Callable[[], None] | None = None + + @callback + def set_is_active(self, active: bool) -> None: + """Set active state.""" + if active != self.is_active: + self.is_active = active + if self._is_active_listener is not None: + self._is_active_listener() + + @callback + def set_is_enabled(self, enabled: bool) -> None: + """Set enabled state.""" + if enabled != self.is_enabled: + self.is_enabled = enabled + if self._is_enabled_listener is not None: + self._is_enabled_listener() + + @callback + def set_pipeline_name(self, pipeline_name: str) -> None: + """Inform listeners that pipeline selection has changed.""" + if pipeline_name != self.pipeline_name: + self.pipeline_name = pipeline_name + if self._pipeline_listener is not None: + self._pipeline_listener() + + @callback + def set_noise_suppression_level(self, noise_suppression_level: int) -> None: + """Set noise suppression level.""" + if noise_suppression_level != self.noise_suppression_level: + self.noise_suppression_level = noise_suppression_level + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_auto_gain(self, auto_gain: int) -> None: + """Set auto gain amount.""" + if auto_gain != self.auto_gain: + self.auto_gain = auto_gain + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_volume_multiplier(self, volume_multiplier: float) -> None: + """Set auto gain amount.""" + if volume_multiplier != self.volume_multiplier: + self.volume_multiplier = volume_multiplier + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: + """Listen for updates to is_active.""" + self._is_active_listener = is_active_listener + + @callback + def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None: + """Listen for updates to is_enabled.""" + self._is_enabled_listener = is_enabled_listener + + @callback + def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: + """Listen for updates to pipeline.""" + self._pipeline_listener = pipeline_listener + + @callback + def set_audio_settings_listener( + self, audio_settings_listener: Callable[[], None] + ) -> None: + """Listen for updates to audio settings.""" + self._audio_settings_listener = audio_settings_listener + + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for assist in progress binary sensor.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress" + ) + + def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for satellite enabled switch.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{self.satellite_id}-satellite_enabled" + ) + + def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for pipeline select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-pipeline" + ) + + def get_noise_suppression_level_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for noise suppression select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-noise_suppression_level" + ) + + def get_auto_gain_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for auto gain amount.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-auto_gain" + ) + + def get_volume_multiplier_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for microphone volume multiplier.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-volume_multiplier" + ) diff --git a/homeassistant/components/wyoming/entity.py b/homeassistant/components/wyoming/entity.py new file mode 100644 index 00000000000..5ed890bc60e --- /dev/null +++ b/homeassistant/components/wyoming/entity.py @@ -0,0 +1,24 @@ +"""Wyoming entities.""" + +from __future__ import annotations + +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN +from .satellite import SatelliteDevice + + +class WyomingSatelliteEntity(entity.Entity): + """Wyoming satellite entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: SatelliteDevice) -> None: + """Initialize entity.""" + self._device = device + self._attr_unique_id = f"{device.satellite_id}-{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.satellite_id)}, + ) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index ddb5407e1ce..7174683fd18 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,7 +3,9 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, + "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.2.0"] + "requirements": ["wyoming==1.4.0"], + "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/models.py b/homeassistant/components/wyoming/models.py new file mode 100644 index 00000000000..dce45d509eb --- /dev/null +++ b/homeassistant/components/wyoming/models.py @@ -0,0 +1,13 @@ +"""Models for wyoming.""" +from dataclasses import dataclass + +from .data import WyomingService +from .satellite import WyomingSatellite + + +@dataclass +class DomainDataItem: + """Domain data item.""" + + service: WyomingService + satellite: WyomingSatellite | None = None diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py new file mode 100644 index 00000000000..5e769eeb06d --- /dev/null +++ b/homeassistant/components/wyoming/number.py @@ -0,0 +1,102 @@ +"""Number entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.number import NumberEntityDescription, RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + +_MAX_AUTO_GAIN: Final = 31 +_MIN_VOLUME_MULTIPLIER: Final = 0.1 +_MAX_VOLUME_MULTIPLIER: Final = 10.0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming number entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatelliteAutoGainNumber(device), + WyomingSatelliteVolumeMultiplierNumber(device), + ] + ) + + +class WyomingSatelliteAutoGainNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent auto gain amount.""" + + entity_description = NumberEntityDescription( + key="auto_gain", + translation_key="auto_gain", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = 0 + _attr_native_max_value = _MAX_AUTO_GAIN + _attr_native_value = 0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None: + await self.async_set_native_value(float(state.state)) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + auto_gain = int(max(0, min(_MAX_AUTO_GAIN, value))) + self._attr_native_value = auto_gain + self.async_write_ha_state() + self._device.set_auto_gain(auto_gain) + + +class WyomingSatelliteVolumeMultiplierNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent microphone volume multiplier.""" + + entity_description = NumberEntityDescription( + key="volume_multiplier", + translation_key="volume_multiplier", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = _MIN_VOLUME_MULTIPLIER + _attr_native_max_value = _MAX_VOLUME_MULTIPLIER + _attr_native_step = 0.1 + _attr_native_value = 1.0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + last_number_data = await self.async_get_last_number_data() + if (last_number_data is not None) and ( + last_number_data.native_value is not None + ): + await self.async_set_native_value(last_number_data.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + self._attr_native_value = float( + max(_MIN_VOLUME_MULTIPLIER, min(_MAX_VOLUME_MULTIPLIER, value)) + ) + self.async_write_ha_state() + self._device.set_volume_multiplier(self._attr_native_value) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py new file mode 100644 index 00000000000..94f61c17047 --- /dev/null +++ b/homeassistant/components/wyoming/satellite.py @@ -0,0 +1,404 @@ +"""Support for Wyoming satellite services.""" +import asyncio +from collections.abc import AsyncGenerator +import io +import logging +from typing import Final +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop +from wyoming.client import AsyncTcpClient +from wyoming.error import Error +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components.assist_pipeline import select as pipeline_select +from homeassistant.core import Context, HomeAssistant + +from .const import DOMAIN +from .data import WyomingService +from .devices import SatelliteDevice + +_LOGGER = logging.getLogger() + +_SAMPLES_PER_CHUNK: Final = 1024 +_RECONNECT_SECONDS: Final = 10 +_RESTART_SECONDS: Final = 3 + +# Wyoming stage -> Assist stage +_STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { + PipelineStage.WAKE: assist_pipeline.PipelineStage.WAKE_WORD, + PipelineStage.ASR: assist_pipeline.PipelineStage.STT, + PipelineStage.HANDLE: assist_pipeline.PipelineStage.INTENT, + PipelineStage.TTS: assist_pipeline.PipelineStage.TTS, +} + + +class WyomingSatellite: + """Remove voice satellite running the Wyoming protocol.""" + + def __init__( + self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + ) -> None: + """Initialize satellite.""" + self.hass = hass + self.service = service + self.device = device + self.is_enabled = True + self.is_running = True + + self._client: AsyncTcpClient | None = None + self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) + self._is_pipeline_running = False + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._pipeline_id: str | None = None + self._enabled_changed_event = asyncio.Event() + + self.device.set_is_enabled_listener(self._enabled_changed) + self.device.set_pipeline_listener(self._pipeline_changed) + self.device.set_audio_settings_listener(self._audio_settings_changed) + + async def run(self) -> None: + """Run and maintain a connection to satellite.""" + _LOGGER.debug("Running satellite task") + + try: + while self.is_running: + try: + # Check if satellite has been disabled + if not self.device.is_enabled: + await self.on_disabled() + if not self.is_running: + # Satellite was stopped while waiting to be enabled + break + + # Connect and run pipeline loop + await self._run_once() + except asyncio.CancelledError: + raise + except Exception: # pylint: disable=broad-exception-caught + await self.on_restart() + finally: + # Ensure sensor is off + self.device.set_is_active(False) + + await self.on_stopped() + + def stop(self) -> None: + """Signal satellite task to stop running.""" + self.is_running = False + + # Unblock waiting for enabled + self._enabled_changed_event.set() + + async def on_restart(self) -> None: + """Block until pipeline loop will be restarted.""" + _LOGGER.warning( + "Unexpected error running satellite. Restarting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RESTART_SECONDS) + + async def on_reconnect(self) -> None: + """Block until a reconnection attempt should be made.""" + _LOGGER.debug( + "Failed to connect to satellite. Reconnecting in %s second(s)", + _RECONNECT_SECONDS, + ) + await asyncio.sleep(_RECONNECT_SECONDS) + + async def on_disabled(self) -> None: + """Block until device may be enabled again.""" + await self._enabled_changed_event.wait() + + async def on_stopped(self) -> None: + """Run when run() has fully stopped.""" + _LOGGER.debug("Satellite task stopped") + + # ------------------------------------------------------------------------- + + def _enabled_changed(self) -> None: + """Run when device enabled status changes.""" + + if not self.device.is_enabled: + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + self._enabled_changed_event.set() + + def _pipeline_changed(self) -> None: + """Run when device pipeline changes.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + def _audio_settings_changed(self) -> None: + """Run when device audio settings.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + + async def _run_once(self) -> None: + """Run pipelines until an error occurs.""" + self.device.set_is_active(False) + + while self.is_running and self.is_enabled: + try: + await self._connect() + break + except ConnectionError: + await self.on_reconnect() + + assert self._client is not None + _LOGGER.debug("Connected to satellite") + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled during connection + return + + # Tell satellite that we're ready + await self._client.write_event(RunSatellite().event()) + + # Wait until we get RunPipeline event + run_pipeline: RunPipeline | None = None + while self.is_running and self.is_enabled: + run_event = await self._client.read_event() + if run_event is None: + raise ConnectionResetError("Satellite disconnected") + + if RunPipeline.is_type(run_event.type): + run_pipeline = RunPipeline.from_event(run_event) + break + + _LOGGER.debug("Unexpected event from satellite: %s", run_event) + + assert run_pipeline is not None + _LOGGER.debug("Received run information: %s", run_pipeline) + + if (not self.is_running) or (not self.is_enabled): + # Run was cancelled or satellite was disabled while waiting for + # RunPipeline event. + return + + start_stage = _STAGES.get(run_pipeline.start_stage) + end_stage = _STAGES.get(run_pipeline.end_stage) + + if start_stage is None: + raise ValueError(f"Invalid start stage: {start_stage}") + + if end_stage is None: + raise ValueError(f"Invalid end stage: {end_stage}") + + # Each loop is a pipeline run + while self.is_running and self.is_enabled: + # Use select to get pipeline each time in case it's changed + pipeline_id = pipeline_select.get_chosen_pipeline( + self.hass, + DOMAIN, + self.device.satellite_id, + ) + pipeline = assist_pipeline.async_get_pipeline(self.hass, pipeline_id) + assert pipeline is not None + + # We will push audio in through a queue + self._audio_queue = asyncio.Queue() + stt_stream = self._stt_stream() + + # Start pipeline running + _LOGGER.debug( + "Starting pipeline %s from %s to %s", + pipeline.name, + start_stage, + end_stage, + ) + self._is_pipeline_running = True + _pipeline_task = asyncio.create_task( + assist_pipeline.async_pipeline_from_audio_stream( + self.hass, + context=Context(), + event_callback=self._event_callback, + stt_metadata=stt.SpeechMetadata( + language=pipeline.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=stt_stream, + start_stage=start_stage, + end_stage=end_stage, + tts_audio_output="wav", + pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + noise_suppression_level=self.device.noise_suppression_level, + auto_gain_dbfs=self.device.auto_gain, + volume_multiplier=self.device.volume_multiplier, + ), + device_id=self.device.device_id, + ) + ) + + # Run until pipeline is complete or cancelled with an empty audio chunk + while self._is_pipeline_running: + client_event = await self._client.read_event() + if client_event is None: + raise ConnectionResetError("Satellite disconnected") + + if AudioChunk.is_type(client_event.type): + # Microphone audio + chunk = AudioChunk.from_event(client_event) + chunk = self._chunk_converter.convert(chunk) + self._audio_queue.put_nowait(chunk.audio) + else: + _LOGGER.debug("Unexpected event from satellite: %s", client_event) + + _LOGGER.debug("Pipeline finished") + + def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: + """Translate pipeline events into Wyoming events.""" + assert self._client is not None + + if event.type == assist_pipeline.PipelineEventType.RUN_END: + # Pipeline run is complete + self._is_pipeline_running = False + self.device.set_is_active(False) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: + self.hass.add_job(self._client.write_event(Detect().event())) + elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: + # Wake word detection + self.device.set_is_active(True) + + # Inform client of wake word detection + if event.data and (wake_word_output := event.data.get("wake_word_output")): + detection = Detection( + name=wake_word_output["wake_word_id"], + timestamp=wake_word_output.get("timestamp"), + ) + self.hass.add_job(self._client.write_event(detection.event())) + elif event.type == assist_pipeline.PipelineEventType.STT_START: + # Speech-to-text + self.device.set_is_active(True) + + if event.data: + self.hass.add_job( + self._client.write_event( + Transcribe(language=event.data["metadata"]["language"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: + # User started speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStarted(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: + # User stopped speaking + if event.data: + self.hass.add_job( + self._client.write_event( + VoiceStopped(timestamp=event.data["timestamp"]).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.STT_END: + # Speech-to-text transcript + if event.data: + # Inform client of transript + stt_text = event.data["stt_output"]["text"] + self.hass.add_job( + self._client.write_event(Transcript(text=stt_text).event()) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_START: + # Text-to-speech text + if event.data: + # Inform client of text + self.hass.add_job( + self._client.write_event( + Synthesize( + text=event.data["tts_input"], + voice=SynthesizeVoice( + name=event.data.get("voice"), + language=event.data.get("language"), + ), + ).event() + ) + ) + elif event.type == assist_pipeline.PipelineEventType.TTS_END: + # TTS stream + if event.data and (tts_output := event.data["tts_output"]): + media_id = tts_output["media_id"] + self.hass.add_job(self._stream_tts(media_id)) + elif event.type == assist_pipeline.PipelineEventType.ERROR: + # Pipeline error + if event.data: + self.hass.add_job( + self._client.write_event( + Error( + text=event.data["message"], code=event.data["code"] + ).event() + ) + ) + + async def _connect(self) -> None: + """Connect to satellite over TCP.""" + _LOGGER.debug( + "Connecting to satellite at %s:%s", self.service.host, self.service.port + ) + self._client = AsyncTcpClient(self.service.host, self.service.port) + await self._client.connect() + + async def _stream_tts(self, media_id: str) -> None: + """Stream TTS WAV audio to satellite in chunks.""" + assert self._client is not None + + extension, data = await tts.async_get_media_source_audio(self.hass, media_id) + if extension != "wav": + raise ValueError(f"Cannot stream audio format to satellite: {extension}") + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + + async def _stt_stream(self) -> AsyncGenerator[bytes, None]: + """Yield audio chunks from a queue.""" + is_first_chunk = True + while chunk := await self._audio_queue.get(): + if is_first_chunk: + is_first_chunk = False + _LOGGER.debug("Receiving audio from satellite") + + yield chunk diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py new file mode 100644 index 00000000000..c04bad4bef8 --- /dev/null +++ b/homeassistant/components/wyoming/select.py @@ -0,0 +1,94 @@ +"""Select entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .devices import SatelliteDevice +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + +_NOISE_SUPPRESSION_LEVEL: Final = { + "off": 0, + "low": 1, + "medium": 2, + "high": 3, + "max": 4, +} +_DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming select entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatellitePipelineSelect(hass, device), + WyomingSatelliteNoiseSuppressionLevelSelect(device), + ] + ) + + +class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): + """Pipeline selector for Wyoming satellites.""" + + def __init__(self, hass: HomeAssistant, device: SatelliteDevice) -> None: + """Initialize a pipeline selector.""" + self.device = device + + WyomingSatelliteEntity.__init__(self, device) + AssistPipelineSelect.__init__(self, hass, device.satellite_id) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + await super().async_select_option(option) + self.device.set_pipeline_name(option) + + +class WyomingSatelliteNoiseSuppressionLevelSelect( + WyomingSatelliteEntity, SelectEntity, restore_state.RestoreEntity +): + """Entity to represent noise suppression level setting.""" + + entity_description = SelectEntityDescription( + key="noise_suppression_level", + translation_key="noise_suppression_level", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = _DEFAULT_NOISE_SUPPRESSION_LEVEL + _attr_options = list(_NOISE_SUPPRESSION_LEVEL.keys()) + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + self._device.set_noise_suppression_level(_NOISE_SUPPRESSION_LEVEL[option]) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index 20d73d8dc13..7b6be68aeb2 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -9,6 +9,10 @@ }, "hassio_confirm": { "description": "Do you want to configure Home Assistant to connect to the Wyoming service provided by the add-on: {addon}?" + }, + "zeroconf_confirm": { + "description": "Do you want to configure Home Assistant to connect to the Wyoming service {name}?", + "title": "Discovered Wyoming service" } }, "error": { @@ -16,7 +20,46 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "no_services": "No services found at endpoint" + "no_services": "No services found at endpoint", + "no_port": "No port for endpoint" + } + }, + "entity": { + "binary_sensor": { + "assist_in_progress": { + "name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]" + } + }, + "select": { + "pipeline": { + "name": "[%key:component::assist_pipeline::entity::select::pipeline::name%]", + "state": { + "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" + } + }, + "noise_suppression_level": { + "name": "Noise suppression level", + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High", + "max": "Max" + } + } + }, + "switch": { + "satellite_enabled": { + "name": "Satellite enabled" + } + }, + "number": { + "auto_gain": { + "name": "Auto gain" + }, + "volume_multiplier": { + "name": "Mic volume" + } } } } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index e64a2f14667..8a21ef051fc 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -24,10 +25,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingSttProvider(config_entry, service), + WyomingSttProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py new file mode 100644 index 00000000000..2bc43122588 --- /dev/null +++ b/homeassistant/components/wyoming/switch.py @@ -0,0 +1,65 @@ +"""Wyoming switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)]) + + +class WyomingSatelliteEnabledSwitch( + WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity +): + """Entity to represent if satellite is enabled.""" + + entity_description = SwitchEntityDescription( + key="satellite_enabled", + translation_key="satellite_enabled", + entity_category=EntityCategory.CONFIG, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + + # Default to on + self._attr_is_on = (state is None) or (state.state == STATE_ON) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + self._device.set_is_enabled(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() + self._device.set_is_enabled(False) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index cde771cd330..f024f925514 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -16,6 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -26,10 +27,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingTtsProvider(config_entry, service), + WyomingTtsProvider(config_entry, item.service), ] ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index fce8bbf6327..da05e8c9fe1 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info from .error import WyomingError +from .models import DomainDataItem _LOGGER = logging.getLogger(__name__) @@ -25,10 +26,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" - service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingWakeWordProvider(hass, config_entry, service), + WyomingWakeWordProvider(hass, config_entry, item.service), ] ) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index a3bb28e7a8b..9be019ed724 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -530,9 +530,6 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -623,9 +620,6 @@ class XiaomiAirPurifierMB4(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -721,9 +715,6 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): This method is a coroutine. """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -809,9 +800,6 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan. This method is a coroutine.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return if await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -958,10 +946,6 @@ class XiaomiFan(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - if preset_mode == ATTR_MODE_NATURE: await self._try_command( "Setting natural fan speed percentage of the miio device failed.", @@ -1034,9 +1018,6 @@ class XiaomiFanP5(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, @@ -1093,9 +1074,6 @@ class XiaomiFanMiot(XiaomiGenericFan): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return await self._try_command( "Setting operation mode of the miio device failed.", self._device.set_mode, diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index c4f28fc750b..d0ee6c030a6 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -6,6 +6,9 @@ "description": "Set up MusicCast to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yamaha MusicCast receiver." } }, "confirm": { diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json index f841f3d3ed1..fcaef65ee3e 100644 --- a/homeassistant/components/yardian/strings.json +++ b/homeassistant/components/yardian/strings.json @@ -5,6 +5,9 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yardian Smart Sprinkler Controller. You can find it in the Yardian app." } } }, diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index ab22f42dae3..72baec52c85 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -6,6 +6,9 @@ "description": "If you leave the host empty, discovery will be used to find devices.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yeelight Wi-Fi bulb." } }, "pick_device": { diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 7322c58ae04..a42687a3551 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.1"] + "requirements": ["yolink-api==0.3.4"] } diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json index 563e6834ddd..e0eddd7d137 100644 --- a/homeassistant/components/youless/strings.json +++ b/homeassistant/components/youless/strings.json @@ -5,6 +5,9 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your YouLess device." } } }, diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index df17672231e..f83e38002b8 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.0"] + "requirements": ["zamg==0.3.3"] } diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 5eb77b0c41c..6738431b304 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.127.0"] + "requirements": ["zeroconf==0.128.4"] } diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index 0e2e23f244c..b75bbe781ef 100644 --- a/homeassistant/components/zeversolar/strings.json +++ b/homeassistant/components/zeversolar/strings.json @@ -4,6 +4,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Zeversolar inverter." } } }, diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 222c7f1d4ef..2046070d6a5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -9,12 +9,12 @@ import re import voluptuous as vol from zhaquirks import setup as setup_quirks from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH -from zigpy.exceptions import NetworkSettingsInconsistent +from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -29,6 +29,7 @@ from .core.const import ( CONF_CUSTOM_QUIRKS_PATH, CONF_DEVICE_CONFIG, CONF_ENABLE_QUIRKS, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, CONF_ZIGPY, @@ -36,6 +37,8 @@ from .core.const import ( DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, + STARTUP_FAILURE_DELAY_S, + STARTUP_RETRIES, RadioType, ) from .core.device import get_device_automation_triggers @@ -158,42 +161,67 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) + # Retry setup a few times before giving up to deal with missing serial ports in VMs + for attempt in range(STARTUP_RETRIES): + try: + zha_gateway = await ZHAGateway.async_from_config( + hass=hass, + config=zha_data.yaml_config, + config_entry=config_entry, + ) + break + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise ConfigEntryError( + "Network settings do not match most recent backup" + ) from exc + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Couldn't start coordinator (attempt %s of %s)", + attempt + 1, + STARTUP_RETRIES, + exc_info=exc, + ) - try: - await zha_gateway.async_initialize() - except NetworkSettingsInconsistent as exc: - await warn_on_inconsistent_network_settings( - hass, - config_entry=config_entry, - old_state=exc.old_state, - new_state=exc.new_state, - ) - raise HomeAssistantError( - "Network settings do not match most recent backup" - ) from exc - except Exception: - if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: - try: - await warn_on_wrong_silabs_firmware( - hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - ) - except AlreadyRunningEZSP as exc: - # If connecting fails but we somehow probe EZSP (e.g. stuck in the - # bootloader), reconnect, it should work - raise ConfigEntryNotReady from exc + if attempt < STARTUP_RETRIES - 1: + await asyncio.sleep(STARTUP_FAILURE_DELAY_S) + continue - raise + if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: + try: + # Ignore all exceptions during probing, they shouldn't halt setup + await warn_on_wrong_silabs_firmware( + hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + ) + except AlreadyRunningEZSP as ezsp_exc: + raise ConfigEntryNotReady from ezsp_exc + + raise repairs.async_delete_blocking_issues(hass) + manufacturer = zha_gateway.state.node_info.manufacturer + model = zha_gateway.state.node_info.model + + if manufacturer is None and model is None: + manufacturer = "Unknown" + model = "Unknown" + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, - identifiers={(DOMAIN, str(zha_gateway.coordinator_ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.state.node_info.ieee))}, + identifiers={(DOMAIN, str(zha_gateway.state.node_info.ieee))}, name="Zigbee Coordinator", - manufacturer="ZHA", - model=zha_gateway.radio_description, + manufacturer=manufacturer, + model=model, + sw_version=zha_gateway.state.node_info.version, ) websocket_api.async_load_api(hass) @@ -267,5 +295,23 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.version = 3 hass.config_entries.async_update_entry(config_entry, data=data) + if config_entry.version == 3: + data = {**config_entry.data} + + if not data[CONF_DEVICE].get(CONF_BAUDRATE): + data[CONF_DEVICE][CONF_BAUDRATE] = { + "deconz": 38400, + "xbee": 57600, + "ezsp": 57600, + "znp": 115200, + "zigate": 115200, + }[data[CONF_RADIO_TYPE]] + + if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): + data[CONF_DEVICE][CONF_FLOW_CONTROL] = None + + config_entry.version = 4 + hass.config_entries.async_update_entry(config_entry, data=data) + _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1b6bbee5159..60cf917d9f6 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -27,12 +27,13 @@ from homeassistant.util import dt as dt_util from .core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, RadioType, ) from .radio_manager import ( + DEVICE_SCHEMA, HARDWARE_DISCOVERY_SCHEMA, RECOMMENDED_RADIOS, ProbeResult, @@ -42,7 +43,7 @@ from .radio_manager import ( CONF_MANUAL_PATH = "Enter Manually" SUPPORTED_PORT_SETTINGS = ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, ) DECONZ_DOMAIN = "deconz" @@ -160,7 +161,7 @@ class BaseZhaFlow(FlowHandler): return self.async_create_entry( title=self._title, data={ - CONF_DEVICE: device_settings, + CONF_DEVICE: DEVICE_SCHEMA(device_settings), CONF_RADIO_TYPE: self._radio_mgr.radio_type.name, }, ) @@ -281,7 +282,7 @@ class BaseZhaFlow(FlowHandler): for ( param, value, - ) in self._radio_mgr.radio_type.controller.SCHEMA_DEVICE.schema.items(): + ) in DEVICE_SCHEMA.schema.items(): if param not in SUPPORTED_PORT_SETTINGS: continue @@ -488,7 +489,7 @@ class BaseZhaFlow(FlowHandler): class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" - VERSION = 3 + VERSION = 4 async def _set_unique_id_or_update_path( self, unique_id: str, device_path: str @@ -646,22 +647,17 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN name = discovery_data["name"] radio_type = self._radio_mgr.parse_radio_type(discovery_data["radio_type"]) - - try: - device_settings = radio_type.controller.SCHEMA_DEVICE( - discovery_data["port"] - ) - except vol.Invalid: - return self.async_abort(reason="invalid_hardware_data") + device_settings = discovery_data["port"] + device_path = device_settings[CONF_DEVICE_PATH] await self._set_unique_id_or_update_path( - unique_id=f"{name}_{radio_type.name}_{device_settings[CONF_DEVICE_PATH]}", - device_path=device_settings[CONF_DEVICE_PATH], + unique_id=f"{name}_{radio_type.name}_{device_path}", + device_path=device_path, ) self._title = name self._radio_mgr.radio_type = radio_type - self._radio_mgr.device_path = device_settings[CONF_DEVICE_PATH] + self._radio_mgr.device_path = device_path self._radio_mgr.device_settings = device_settings self.context["title_placeholders"] = {CONF_NAME: name} diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9874fddc598..f89ed8d9a52 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -127,6 +127,7 @@ CONF_ALARM_FAILED_TRIES = "alarm_failed_tries" CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" CONF_BAUDRATE = "baudrate" +CONF_FLOW_CONTROL = "flow_control" CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" CONF_DEVICE_CONFIG = "device_config" @@ -136,7 +137,6 @@ CONF_ALWAYS_PREFER_XY_COLOR_MODE = "always_prefer_xy_color_mode" CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" -CONF_FLOWCONTROL = "flow_control" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 44acbb172fc..0ce6f47b61e 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -285,7 +285,7 @@ class ZHADevice(LogMixin): if not self.is_coordinator: return False - return self.ieee == self.gateway.coordinator_ieee + return self.ieee == self.gateway.state.node_info.ieee @property def is_end_device(self) -> bool | None: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b4c02d33015..5c038a2d7f8 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -11,7 +11,7 @@ import itertools import logging import re import time -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, Self from zigpy.application import ControllerApplication from zigpy.config import ( @@ -24,15 +24,14 @@ from zigpy.config import ( ) import zigpy.device import zigpy.endpoint -from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError import zigpy.group +from zigpy.state import State from zigpy.types.named import EUI64 from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components.system_log import LogEntry, _figure_out_source from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -66,8 +65,6 @@ from .const import ( SIGNAL_ADD_ENTITIES, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, - STARTUP_FAILURE_DELAY_S, - STARTUP_RETRIES, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, ZHA_GW_MSG, @@ -123,10 +120,6 @@ class DevicePairingStatus(Enum): class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" - # -- Set in async_initialize -- - application_controller: ControllerApplication - radio_description: str - def __init__( self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry ) -> None: @@ -135,7 +128,8 @@ class ZHAGateway: self._config = config self._devices: dict[EUI64, ZHADevice] = {} self._groups: dict[int, ZHAGroup] = {} - self.coordinator_zha_device: ZHADevice | None = None + self.application_controller: ControllerApplication = None + self.coordinator_zha_device: ZHADevice = None # type: ignore[assignment] self._device_registry: collections.defaultdict[ EUI64, list[EntityReference] ] = collections.defaultdict(list) @@ -147,13 +141,11 @@ class ZHAGateway: self._log_relay_handler = LogRelayHandler(hass, self) self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] + self.shutting_down = False def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" - radio_type = self.config_entry.data[CONF_RADIO_TYPE] - - app_controller_cls = RadioType[radio_type].controller - self.radio_description = RadioType[radio_type].description + radio_type = RadioType[self.config_entry.data[CONF_RADIO_TYPE]] app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( @@ -170,7 +162,7 @@ class ZHAGateway: # event loop, when a connection to a TCP coordinator fails in a specific way if ( CONF_USE_THREAD not in app_config - and RadioType[radio_type] is RadioType.ezsp + and radio_type is RadioType.ezsp and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") ): app_config[CONF_USE_THREAD] = False @@ -189,48 +181,40 @@ class ZHAGateway: ): app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 - return app_controller_cls, app_controller_cls.SCHEMA(app_config) + return radio_type.controller, radio_type.controller.SCHEMA(app_config) + + @classmethod + async def async_from_config( + cls, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry + ) -> Self: + """Create an instance of a gateway from config objects.""" + instance = cls(hass, config, config_entry) + await instance.async_initialize() + return instance async def async_initialize(self) -> None: """Initialize controller and connect radio.""" discovery.PROBE.initialize(self.hass) discovery.GROUP_PROBE.initialize(self.hass) + self.shutting_down = False + app_controller_cls, app_config = self.get_application_controller_data() - self.application_controller = await app_controller_cls.new( + app = await app_controller_cls.new( config=app_config, auto_form=False, start_radio=False, ) try: - for attempt in range(STARTUP_RETRIES): - try: - await self.application_controller.startup(auto_form=True) - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except NetworkSettingsInconsistent: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.debug( - "Couldn't start %s coordinator (attempt %s of %s)", - self.radio_description, - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) - - if attempt == STARTUP_RETRIES - 1: - raise exc - - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - else: - break + await app.startup(auto_form=True) except Exception: # Explicitly shut down the controller application on failure - await self.application_controller.shutdown() + await app.shutdown() raise + self.application_controller = app + zha_data = get_zha_data(self.hass) zha_data.gateway = self @@ -244,6 +228,17 @@ class ZHAGateway: self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) + def connection_lost(self, exc: Exception) -> None: + """Handle connection lost event.""" + if self.shutting_down: + return + + _LOGGER.debug("Connection to the radio was lost: %r", exc) + + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + def _find_coordinator_device(self) -> zigpy.device.Device: zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) @@ -258,6 +253,7 @@ class ZHAGateway: @callback def async_load_devices(self) -> None: """Restore ZHA devices from zigpy application state.""" + for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) delta_msg = "not known" @@ -280,6 +276,7 @@ class ZHAGateway: @callback def async_load_groups(self) -> None: """Initialize ZHA groups.""" + for group_id in self.application_controller.groups: group = self.application_controller.groups[group_id] zha_group = self._async_get_or_create_group(group) @@ -521,9 +518,9 @@ class ZHAGateway: entity_registry.async_remove(entry.entity_id) @property - def coordinator_ieee(self) -> EUI64: - """Return the active coordinator's IEEE address.""" - return self.application_controller.state.node_info.ieee + def state(self) -> State: + """Return the active coordinator's network state.""" + return self.application_controller.state @property def devices(self) -> dict[EUI64, ZHADevice]: @@ -711,6 +708,7 @@ class ZHAGateway: group_id: int | None = None, ) -> ZHAGroup | None: """Create a new Zigpy Zigbee group.""" + # we start with two to fill any gaps from a user removing existing groups if group_id is None: @@ -758,19 +756,13 @@ class ZHAGateway: async def shutdown(self) -> None: """Stop ZHA Controller Application.""" _LOGGER.debug("Shutting down ZHA ControllerApplication") + self.shutting_down = True + for unsubscribe in self._unsubs: unsubscribe() for device in self.devices.values(): device.async_cleanup_handles() - # shutdown is called when the config entry unloads are processed - # there are cases where unloads are processed because of a failure of - # some sort and the application controller may not have been - # created yet - if ( - hasattr(self, "application_controller") - and self.application_controller is not None - ): - await self.application_controller.shutdown() + await self.application_controller.shutdown() def handle_message( self, diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 4bdedebfff9..87f59f31e9b 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -253,7 +253,7 @@ class MatchRule: else: matches.append(model in self.models) - if self.quirk_ids and quirk_id: + if self.quirk_ids: if callable(self.quirk_ids): matches.append(self.quirk_ids(quirk_id)) else: diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 05e1da7c570..b92d077907f 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -92,7 +92,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.coordinator_ieee), + via_device=(DOMAIN, zha_gateway.state.node_info.ieee), ) @callback diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 05bf3469c7b..7364aed0d1b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -13,7 +13,6 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -21,10 +20,10 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions @@ -131,11 +130,6 @@ class BaseFan(FanEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the fan.""" - if preset_mode not in self.preset_modes: - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) @abstractmethod diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index af2c8405e5f..4c8a58a12cf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.8", + "bellows==0.37.1", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.106", - "zigpy-deconz==0.21.1", - "zigpy==0.59.0", - "zigpy-xbee==0.19.0", - "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.6", - "universal-silabs-flasher==0.0.14", + "zha-quirks==0.0.107", + "zigpy-deconz==0.22.0", + "zigpy==0.60.0", + "zigpy-xbee==0.20.0", + "zigpy-zigate==0.12.0", + "zigpy-znp==0.12.0", + "universal-silabs-flasher==0.0.15", "pyserial-asyncio-fast==0.11" ], "usb": [ @@ -76,6 +76,12 @@ "description": "*conbee*", "known_devices": ["Conbee II"] }, + { + "vid": "0403", + "pid": "6015", + "description": "*conbee*", + "known_devices": ["Conbee III"] + }, { "vid": "10C4", "pid": "8A2A", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index d20cf752a91..d3ca03de8d8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -19,6 +19,7 @@ from zigpy.config import ( CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED, + SCHEMA_DEVICE, ) from zigpy.exceptions import NetworkNotFormed @@ -58,10 +59,21 @@ RETRY_DELAY_S = 1.0 BACKUP_RETRIES = 5 MIGRATION_RETRIES = 100 + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required("path"): str, + vol.Optional("baudrate", default=115200): int, + vol.Optional("flow_control", default=None): vol.In( + ["hardware", "software", None] + ), + } +) + HARDWARE_DISCOVERY_SCHEMA = vol.Schema( { vol.Required("name"): str, - vol.Required("port"): dict, + vol.Required("port"): DEVICE_SCHEMA, vol.Required("radio_type"): str, } ) @@ -204,9 +216,7 @@ class ZhaRadioManager: for radio in AUTOPROBE_RADIOS: _LOGGER.debug("Attempting to probe radio type %s", radio) - dev_config = radio.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + dev_config = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) probe_result = await radio.controller.probe(dev_config) if not probe_result: @@ -357,7 +367,7 @@ class ZhaMultiPANMigrationHelper: migration_data["new_discovery_info"]["radio_type"] ) - new_device_settings = new_radio_type.controller.SCHEMA_DEVICE( + new_device_settings = SCHEMA_DEVICE( migration_data["new_discovery_info"]["port"] ) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a8b3d300e3b..ccadc452bc7 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -449,7 +449,10 @@ class ControllerEvents: "remove_entity" ), ) - elif reason == RemoveNodeReason.RESET: + # We don't want to remove the device so we can keep the user customizations + return + + if reason == RemoveNodeReason.RESET: device_name = device.name_by_user or device.name or f"Node {node.node_id}" identifier = get_network_identifier_for_notification( self.hass, self.config_entry, self.driver_events.driver.controller @@ -471,8 +474,8 @@ class ControllerEvents: "Device Was Factory Reset!", f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", ) - else: - self.remove_device(device) + + self.remove_device(device) @callback def async_on_identify(self, event: dict) -> None: diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 9e50b55830c..7f4855bfbe5 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,10 +1,10 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import dataclasses from functools import partial, wraps -from typing import Any, Literal, cast +from typing import Any, Concatenate, Literal, ParamSpec, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -85,6 +85,8 @@ from .helpers import ( get_device_id, ) +_P = ParamSpec("_P") + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -264,8 +266,11 @@ QR_CODE_STRING_SCHEMA = vol.All(str, vol.Length(min=MINIMUM_QR_STRING_LENGTH)) async def _async_get_entry( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, entry_id: str -) -> tuple[ConfigEntry | None, Client | None, Driver | None]: + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry_id: str, +) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -293,19 +298,26 @@ async def _async_get_entry( return entry, client, client.driver -def async_get_entry(orig_func: Callable) -> Callable: +def async_get_entry( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get entry.""" @wraps(orig_func) async def async_get_entry_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" entry, client, driver = await _async_get_entry( hass, connection, msg, msg[ENTRY_ID] ) - if not entry and not client and not driver: + if not entry or not client or not driver: return await orig_func(hass, connection, msg, entry, client, driver) @@ -328,12 +340,19 @@ async def _async_get_node( return node -def async_get_node(orig_func: Callable) -> Callable: +def async_get_node( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Node], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get node.""" @wraps(orig_func) async def async_get_node_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID]) @@ -344,16 +363,24 @@ def async_get_node(orig_func: Callable) -> Callable: return async_get_node_func -def async_handle_failed_command(orig_func: Callable) -> Callable: +def async_handle_failed_command( + orig_func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate async function to handle FailedCommand and send relevant error.""" @wraps(orig_func) async def async_handle_failed_command_func( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, - *args: Any, - **kwargs: Any, + msg: dict[str, Any], + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle FailedCommand within function and send relevant error.""" try: diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index d0630649765..d4247b65c8b 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -18,7 +18,6 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, FanEntity, FanEntityFeature, - NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -181,11 +180,6 @@ class ValueMappingZwaveFan(ZwaveFan): await self._async_set_value(self._target_value, zwave_value) return - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) - @property def available(self) -> bool: """Return whether the entity is available.""" diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5d78d3e57e7..65c77f8ab2d 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -456,7 +456,9 @@ def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: return {key: value for key, value in config.items() if value not in ("", None)} -def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: +def check_type_schema_map( + schema_map: dict[str, vol.Schema] +) -> Callable[[ConfigType], ConfigType]: """Check type specific schema against config.""" def _check_type_schema(config: ConfigType) -> ConfigType: diff --git a/homeassistant/config.py b/homeassistant/config.py index b4850e372fd..95dd42737a0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from dataclasses import dataclass from enum import StrEnum @@ -48,6 +48,7 @@ from .const import ( CONF_MEDIA_DIRS, CONF_NAME, CONF_PACKAGES, + CONF_PLATFORM, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, CONF_TYPE, @@ -58,19 +59,14 @@ from .const import ( from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback from .exceptions import ConfigValidationError, HomeAssistantError from .generated.currencies import HISTORIC_CURRENCIES -from .helpers import ( - config_per_platform, - config_validation as cv, - extract_domain_configs, - issue_registry as ir, -) +from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system -from .util.yaml import SECRET_YAML, Secrets, load_yaml +from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -145,7 +141,7 @@ class ConfigExceptionInfo: exception: Exception translation_key: ConfigErrorTranslationKey - platform_name: str + platform_path: str config: ConfigType integration_link: str | None @@ -453,6 +449,19 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name) raise + invalid_domains = [] + for key in config: + try: + cv.domain_key(key) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error("Invalid domain '%s'%s", key, suffix) + invalid_domains.append(key) + for invalid_domain in invalid_domains: + config.pop(invalid_domain) + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config @@ -467,15 +476,15 @@ def load_yaml_config_file( This method needs to run in an executor. """ - conf_dict = load_yaml(config_path, secrets) - - if not isinstance(conf_dict, dict): + try: + conf_dict = load_yaml_dict(config_path, secrets) + except YamlTypeError as exc: msg = ( f"The configuration file {os.path.basename(config_path)} " "does not contain a dictionary" ) _LOGGER.error(msg) - raise HomeAssistantError(msg) + raise HomeAssistantError(msg) from exc # Convert values to dictionaries if they are None for key, value in conf_dict.items(): @@ -663,7 +672,14 @@ def stringify_invalid( - Give a more user friendly output for unknown options - Give a more user friendly output for missing options """ - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" if domain != CONF_CORE and link: message_suffix = f", please check the docs at {link}" else: @@ -734,7 +750,14 @@ def format_homeassistant_error( link: str | None = None, ) -> str: """Format HomeAssistantError thrown by a custom config validator.""" - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" # HomeAssistantError raised by custom config validator has no path to the # offending configuration key, use the domain key as path instead. if annotation := find_annotation(config, [domain]): @@ -972,9 +995,13 @@ async def merge_packages_config( for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - # If component name is given with a trailing description, remove it - # when looking for component - domain = comp_name.partition(" ")[0] + try: + domain = cv.domain_key(comp_name) + except vol.Invalid: + _log_pkg_error( + hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'" + ) + continue try: integration = await async_get_integration_with_requirements( @@ -1068,7 +1095,7 @@ def _get_log_message_and_stack_print_pref( ) -> tuple[str | None, bool, dict[str, str]]: """Get message to log and print stack trace preference.""" exception = platform_exception.exception - platform_name = platform_exception.platform_name + platform_path = platform_exception.platform_path platform_config = platform_exception.config link = platform_exception.integration_link @@ -1092,7 +1119,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( - f"Unknown error validating {platform_name} platform config with {domain} " + f"Unknown error validating {platform_path} platform config with {domain} " "component platform schema", True, ), @@ -1105,7 +1132,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: ( - f"Unknown error validating config for {platform_name} platform " + f"Unknown error validating config for {platform_path} platform " f"for {domain} component with PLATFORM_SCHEMA", True, ), @@ -1119,7 +1146,7 @@ def _get_log_message_and_stack_print_pref( show_stack_trace = False if isinstance(exception, vol.Invalid): log_message = format_schema_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) if annotation := find_annotation(platform_config, exception.path): placeholders["config_file"], line = annotation @@ -1128,9 +1155,9 @@ def _get_log_message_and_stack_print_pref( if TYPE_CHECKING: assert isinstance(exception, HomeAssistantError) log_message = format_homeassistant_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) - if annotation := find_annotation(platform_config, [platform_name]): + if annotation := find_annotation(platform_config, [platform_path]): placeholders["config_file"], line = annotation placeholders["line"] = str(line) show_stack_trace = True @@ -1222,6 +1249,46 @@ def async_handle_component_errors( ) +def config_per_platform( + config: ConfigType, domain: str +) -> Iterable[tuple[str | None, ConfigType]]: + """Break a component config into different platforms. + + For example, will find 'switch', 'switch 2', 'switch 3', .. etc + Async friendly. + """ + for config_key in extract_domain_configs(config, domain): + if not (platform_config := config[config_key]): + continue + + if not isinstance(platform_config, list): + platform_config = [platform_config] + + item: ConfigType + platform: str | None + for item in platform_config: + try: + platform = item.get(CONF_PLATFORM) + except AttributeError: + platform = None + + yield platform, item + + +def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: + """Extract keys from config for given domain name. + + Async friendly. + """ + domain_configs = [] + for key in config: + with suppress(vol.Invalid): + if cv.domain_key(key) != domain: + continue + domain_configs.append(key) + return domain_configs + + async def async_process_component_config( # noqa: C901 hass: HomeAssistant, config: ConfigType, @@ -1332,7 +1399,7 @@ async def async_process_component_config( # noqa: C901 platforms: list[ConfigType] = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema - platform_name = f"{domain}.{p_name}" + platform_path = f"{p_name}.{domain}" try: p_validated = component_platform_schema(p_config) except vol.Invalid as exc: @@ -1369,7 +1436,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1382,7 +1449,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1397,7 +1464,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, - platform_name, + platform_path, p_config, p_integration.documentation, ) diff --git a/homeassistant/const.py b/homeassistant/const.py index c6655ba3900..8da1c251b4e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,8 +5,8 @@ from enum import StrEnum from typing import Final APPLICATION_NAME: Final = "HomeAssistant" -MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 12 +MAJOR_VERSION: Final = 2024 +MINOR_VERSION: Final = 1 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/core.py b/homeassistant/core.py index 972e0e618e6..7f0883ca880 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -18,6 +18,7 @@ from collections.abc import ( ) import concurrent.futures from contextlib import suppress +from dataclasses import dataclass import datetime import enum import functools @@ -80,7 +81,6 @@ from .exceptions import ( ServiceNotFound, Unauthorized, ) -from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior from .helpers.json import json_dumps from .util import dt as dt_util, location from .util.async_ import ( @@ -108,12 +108,12 @@ if TYPE_CHECKING: from .helpers.entity import StateInfo -STAGE_1_SHUTDOWN_TIMEOUT = 100 -STAGE_2_SHUTDOWN_TIMEOUT = 60 -STAGE_3_SHUTDOWN_TIMEOUT = 30 +STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 +STOP_STAGE_SHUTDOWN_TIMEOUT = 100 +FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 +CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() -restore_original_aiohttp_cancel_behavior() _T = TypeVar("_T") _R = TypeVar("_R") @@ -301,6 +301,14 @@ class HassJob(Generic[_P, _R_co]): return f"" +@dataclass(frozen=True) +class HassJobWithArgs: + """Container for a HassJob and arguments.""" + + job: HassJob[..., Coroutine[Any, Any, Any] | Any] + args: Iterable[Any] + + def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: """Determine the job type from the callable.""" # Check for partials to properly determine if coroutine function @@ -372,6 +380,7 @@ class HomeAssistant: # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None + self._shutdown_jobs: list[HassJobWithArgs] = [] @property def is_running(self) -> bool: @@ -768,6 +777,42 @@ class HomeAssistant: for task in pending: _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any + ) -> CALLBACK_TYPE: + ... + + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + ... + + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + """Add a HassJob which will be executed on shutdown. + + This method must be run in the event loop. + + hassjob: HassJob + args: parameters for method to call. + + Returns function to remove the job. + """ + job_with_args = HassJobWithArgs(hassjob, args) + self._shutdown_jobs.append(job_with_args) + + @callback + def remove_job() -> None: + self._shutdown_jobs.remove(job_with_args) + + return remove_job + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore @@ -801,6 +846,26 @@ class HomeAssistant: "Stopping Home Assistant before startup has completed may fail" ) + # Stage 1 - Run shutdown jobs + try: + async with self.timeout.async_timeout(STOPPING_STAGE_SHUTDOWN_TIMEOUT): + tasks: list[asyncio.Future[Any]] = [] + for job in self._shutdown_jobs: + task_or_none = self.async_run_hass_job(job.job, *job.args) + if not task_or_none: + continue + tasks.append(task_or_none) + if tasks: + asyncio.gather(*tasks, return_exceptions=True) + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown jobs to complete, the shutdown will" + " continue" + ) + self._async_log_running_tasks("run shutdown jobs") + + # Stage 2 - Stop integrations + # Keep holding the reference to the tasks but do not allow them # to block shutdown. Only tasks created after this point will # be waited for. @@ -818,33 +883,32 @@ class HomeAssistant: self.exit_code = exit_code - # stage 1 self.state = CoreState.stopping self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: - async with self.timeout.async_timeout(STAGE_1_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 1 to complete, the shutdown will" + "Timed out waiting for integrations to stop, the shutdown will" " continue" ) - self._async_log_running_tasks(1) + self._async_log_running_tasks("stop integrations") - # stage 2 + # Stage 3 - Final write self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: - async with self.timeout.async_timeout(STAGE_2_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 2 to complete, the shutdown will" + "Timed out waiting for final writes to complete, the shutdown will" " continue" ) - self._async_log_running_tasks(2) + self._async_log_running_tasks("final write") - # stage 3 + # Stage 4 - Close self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) @@ -858,12 +922,12 @@ class HomeAssistant: # were awaiting another task continue _LOGGER.warning( - "Task %s was still running after stage 2 shutdown; " + "Task %s was still running after final writes shutdown stage; " "Integrations should cancel non-critical tasks when receiving " "the stop event to prevent delaying shutdown", task, ) - task.cancel("Home Assistant stage 2 shutdown") + task.cancel("Home Assistant final writes shutdown stage") try: async with asyncio.timeout(0.1): await task @@ -872,11 +936,11 @@ class HomeAssistant: except asyncio.TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( - "Task %s could not be canceled during stage 3 shutdown", task + "Task %s could not be canceled during final shutdown stage", task ) except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( - "Task %s error during stage 3 shutdown: %s", task, exc + "Task %s error during final shutdown stage: %s", task, exc ) # Prevent run_callback_threadsafe from scheduling any additional @@ -887,14 +951,14 @@ class HomeAssistant: shutdown_run_callback_threadsafe(self.loop) try: - async with self.timeout.async_timeout(STAGE_3_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 3 to complete, the shutdown will" + "Timed out waiting for close event to be processed, the shutdown will" " continue" ) - self._async_log_running_tasks(3) + self._async_log_running_tasks("close") self.state = CoreState.stopped @@ -914,10 +978,10 @@ class HomeAssistant: ): handle.cancel() - def _async_log_running_tasks(self, stage: int) -> None: + def _async_log_running_tasks(self, stage: str) -> None: """Log all running tasks.""" for task in self._tasks: - _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + _LOGGER.warning("Shutdown stage '%s': still running: %s", stage, task) class Context: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 57503f0ef32..975bfc60688 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -46,6 +46,7 @@ FLOWS = { "androidtv_remote", "anova", "anthemav", + "aosmith", "apcupsd", "apple_tv", "aranet", @@ -149,6 +150,7 @@ FLOWS = { "fitbit", "fivem", "fjaraskupan", + "flexit_bacnet", "flick_electric", "flipr", "flo", @@ -200,6 +202,7 @@ FLOWS = { "hisense_aehw4a1", "hive", "hlk_sw16", + "holiday", "home_connect", "home_plus_control", "homeassistant_sky_connect", @@ -246,7 +249,6 @@ FLOWS = { "kmtronic", "knx", "kodi", - "komfovent", "konnected", "kostal_plenticore", "kraken", @@ -347,6 +349,7 @@ FLOWS = { "openweathermap", "opower", "oralb", + "osoenergy", "otbr", "ourgroceries", "overkiz", @@ -469,6 +472,7 @@ FLOWS = { "stookwijzer", "subaru", "sun", + "sunweg", "surepetcare", "switchbee", "switchbot", @@ -486,6 +490,7 @@ FLOWS = { "tautulli", "tellduslive", "tesla_wall_connector", + "tessie", "thermobeacon", "thermopro", "thread", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f0af72624f6..33e2229eb2e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -286,6 +286,12 @@ "integration_type": "virtual", "supported_by": "energyzero" }, + "aosmith": { + "name": "A. O. Smith", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "apache_kafka": { "name": "Apache Kafka", "integration_type": "hub", @@ -1766,9 +1772,20 @@ }, "flexit": { "name": "Flexit", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "integrations": { + "flexit": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Flexit" + }, + "flexit_bacnet": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Flexit Nordic (BACnet)" + } + } }, "flexom": { "name": "Bouygues Flexom", @@ -1930,6 +1947,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "fujitsu_anywair": { + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" + }, "fully_kiosk": { "name": "Fully Kiosk Browser", "integration_type": "hub", @@ -2397,6 +2419,12 @@ "config_flow": true, "iot_class": "local_push" }, + "holiday": { + "name": "Holiday", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "home_connect": { "name": "Home Connect", "integration_type": "hub", @@ -2881,12 +2909,6 @@ "config_flow": true, "iot_class": "local_push" }, - "komfovent": { - "name": "Komfovent", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "konnected": { "name": "Konnected.io", "integration_type": "hub", @@ -4134,6 +4156,12 @@ "config_flow": false, "iot_class": "local_push" }, + "osoenergy": { + "name": "OSO Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", @@ -4937,6 +4965,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "scl": { + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" + }, "scrape": { "name": "Scrape", "integration_type": "hub", @@ -5512,6 +5545,12 @@ "config_flow": true, "iot_class": "calculated" }, + "sunweg": { + "name": "Sun WEG", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "supervisord": { "name": "Supervisord", "integration_type": "hub", @@ -5763,6 +5802,12 @@ } } }, + "tessie": { + "name": "Tessie", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tfiac": { "name": "Tfiac", "integration_type": "hub", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index f58936caf8d..2fdd032c2dd 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -81,6 +81,12 @@ USB = [ "pid": "0030", "vid": "1CF1", }, + { + "description": "*conbee*", + "domain": "zha", + "pid": "6015", + "vid": "0403", + }, { "description": "*zigbee*", "domain": "zha", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e8d117d1f33..55570078d80 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -715,6 +715,11 @@ ZEROCONF = { "domain": "wled", }, ], + "_wyoming._tcp.local.": [ + { + "domain": "wyoming", + }, + ], "_xbmc-jsonrpc-h._tcp.local.": [ { "domain": "kodi", diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index c9acdf0d712..52197e83495 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations from collections.abc import Iterable, Sequence -import re from typing import TYPE_CHECKING -from homeassistant.const import CONF_PLATFORM - if TYPE_CHECKING: from .typing import ConfigType @@ -19,22 +16,23 @@ def config_per_platform( For example, will find 'switch', 'switch 2', 'switch 3', .. etc Async friendly. """ - for config_key in extract_domain_configs(config, domain): - if not (platform_config := config[config_key]): - continue + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config - if not isinstance(platform_config, list): - platform_config = [platform_config] + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning - item: ConfigType - platform: str | None - for item in platform_config: - try: - platform = item.get(CONF_PLATFORM) - except AttributeError: - platform = None + _print_deprecation_warning( + config_per_platform, + "config.config_per_platform", + "function", + "called", + "2024.6", + ) + return ha_config.config_per_platform(config, domain) - yield platform, item + +config_per_platform.__name__ = "helpers.config_per_platform" def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: @@ -42,5 +40,20 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config + + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning + + _print_deprecation_warning( + extract_domain_configs, + "config.extract_domain_configs", + "function", + "called", + "2024.6", + ) + return ha_config.extract_domain_configs(config, domain) + + +extract_domain_configs.__name__ = "helpers.extract_domain_configs" diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index fba9bb647dd..74527a5922f 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -298,7 +298,7 @@ def _async_get_connector( return connectors[connector_key] if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.get_default_context() + ssl_context: SSLContext = ssl_util.get_default_context() else: ssl_context = ssl_util.get_default_no_verify_context() diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py deleted file mode 100644 index 6e281b659fe..00000000000 --- a/homeassistant/helpers/aiohttp_compat.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Helper to restore old aiohttp behavior.""" -from __future__ import annotations - -from aiohttp import web, web_protocol, web_server - - -class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): - """Request handler that cancels tasks on disconnect.""" - - def connection_lost(self, exc: BaseException | None) -> None: - """Handle connection lost.""" - task_handler = self._task_handler - super().connection_lost(exc) - if task_handler is not None: - task_handler.cancel("aiohttp connection lost") - - -def restore_original_aiohttp_cancel_behavior() -> None: - """Patch aiohttp to restore cancel behavior. - - Remove this once aiohttp 3.9 is released as we can use - https://github.com/aio-libs/aiohttp/pull/7128 - """ - web_protocol.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] - web_server.RequestHandler = CancelOnDisconnectRequestHandler # type: ignore[misc] - - -def enable_compression(response: web.Response) -> None: - """Enable compression on the response.""" - # - # Set _zlib_executor_size in the constructor once support for - # aiohttp < 3.9.0 is dropped - # - # We want large zlib payloads to be compressed in the executor - # to avoid blocking the event loop. - # - # 32KiB was chosen based on testing in production. - # aiohttp will generate a warning for payloads larger than 1MiB - # - response._zlib_executor_size = 32768 # pylint: disable=protected-access - response.enable_compression() diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 23707949dcd..1c8efadfdc5 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -31,6 +31,7 @@ from homeassistant.requirements import ( ) import homeassistant.util.yaml.loader as yaml_loader +from . import config_validation as cv from .typing import ConfigType @@ -175,7 +176,7 @@ async def async_check_ha_config_file( # noqa: C901 core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections - components = {key.partition(" ")[0] for key in config} + components = {cv.domain_key(key) for key in config} frontend_dependencies: set[str] = set() if "frontend" in components or "default_config" in components: @@ -276,13 +277,17 @@ async def async_check_ha_config_file( # noqa: C901 # show errors for a missing integration in recovery mode or safe mode to # not confuse the user. if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue # Validate platform specific schema diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 18445ba0789..e4b62dd679d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -99,6 +99,7 @@ from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util +from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper @@ -350,6 +351,30 @@ comp_entity_ids_or_uuids = vol.Any( ) +def domain_key(config_key: Any) -> str: + """Validate a top level config key with an optional label and return the domain. + + A domain is separated from a label by one or more spaces, empty labels are not + allowed. + + Examples: + 'hue' returns 'hue' + 'hue 1' returns 'hue' + 'hue 1' returns 'hue' + 'hue ' raises + 'hue ' raises + """ + if not isinstance(config_key, str): + raise vol.Invalid("invalid domain", path=[config_key]) + + parts = config_key.partition(" ") + _domain = parts[0] if parts[2].strip(" ") else config_key + if not _domain or _domain.strip(" ") != _domain: + raise vol.Invalid("invalid domain", path=[config_key]) + + return _domain + + def entity_domain(domain: str | list[str]) -> Callable[[Any], str]: """Validate that entity belong to domain.""" ent_domain = entities_domain(domain) @@ -581,7 +606,11 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if type(value) is str: # noqa: E721 + if ( + type(value) is str # noqa: E721 + or type(value) is NodeStrClass # noqa: E721 + or isinstance(value, str) + ): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index c499dd0b6cd..20dbacde480 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -97,9 +97,13 @@ def get_deprecated( def deprecated_class( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark class as deprecated and provide a replacement class to be used instead.""" + """Mark class as deprecated and provide a replacement class to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(cls: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate class as deprecated.""" @@ -107,7 +111,9 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class") + _print_deprecation_warning( + cls, replacement, "class", "instantiated", breaks_in_ha_version + ) return cls(*args, **kwargs) return deprecated_cls @@ -116,9 +122,13 @@ def deprecated_class( def deprecated_function( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Mark function as deprecated and provide a replacement to be used instead.""" + """Mark function as deprecated and provide a replacement to be used instead. + + If the deprecated function was called from a custom integration, ask the user to + report an issue. + """ def deprecated_decorator(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorate function as deprecated.""" @@ -126,7 +136,9 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function") + _print_deprecation_warning( + func, replacement, "function", "called", breaks_in_ha_version + ) return func(*args, **kwargs) return deprecated_func @@ -134,10 +146,29 @@ def deprecated_function( return deprecated_decorator -def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: +def _print_deprecation_warning( + obj: Any, + replacement: str, + description: str, + verb: str, + breaks_in_ha_version: str | None, +) -> None: logger = logging.getLogger(obj.__module__) + if breaks_in_ha_version: + breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + else: + breaks_in = "" try: integration_frame = get_integration_frame() + except MissingIntegrationFrame: + logger.warning( + "%s is a deprecated %s%s. Use %s instead", + obj.__name__, + description, + breaks_in, + replacement, + ) + else: if integration_frame.custom_integration: hass: HomeAssistant | None = None with suppress(HomeAssistantError): @@ -149,27 +180,24 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> ) logger.warning( ( - "%s was called from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s%s. Use %s instead," " please %s" ), obj.__name__, + verb, integration_frame.integration, description, + breaks_in, replacement, report_issue, ) else: logger.warning( - "%s was called from %s, this is a deprecated %s. Use %s instead", + "%s was %s from %s, this is a deprecated %s%s. Use %s instead", obj.__name__, + verb, integration_frame.integration, description, + breaks_in, replacement, ) - except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 775d0934c36..30e892a8840 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform -from . import config_per_platform, config_validation as cv, discovery, entity, service +from . import config_validation as cv, discovery, entity, service from .entity_platform import EntityPlatform from .typing import ConfigType, DiscoveryInfoType @@ -148,7 +148,7 @@ class EntityComponent(Generic[_EntityT]): self.config = config # Look in config for Domain, Domain 2, Domain 3 etc and load them - for p_type, p_config in config_per_platform(config, self.domain): + for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: self.hass.async_create_task( self.async_setup_platform(p_type, p_config), diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2fc82567739..be087241287 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -304,7 +304,7 @@ class EntityPlatform: current_platform.set(self) logger = self.logger hass = self.hass - full_name = f"{self.domain}.{self.platform_name}" + full_name = f"{self.platform_name}.{self.domain}" object_id_language = ( hass.config.language if hass.config.language in languages.NATIVE_ENTITY_IDS diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 42ebc2d0869..983b4e2da52 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from . import config_per_platform from .entity import Entity from .entity_component import EntityComponent from .entity_platform import EntityPlatform, async_get_platforms @@ -69,7 +68,7 @@ async def _resetup_platform( root_config: dict[str, list[ConfigType]] = {platform_domain: []} # Extract only the config for template, ignore the rest. - for p_type, p_config in config_per_platform(conf, platform_domain): + for p_type, p_config in conf_util.config_per_platform(conf, platform_domain): if p_type != integration_domain: continue diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index dcf7f07bf6b..2bbad0ed63a 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -331,7 +331,12 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): return cls.options_flow is not None @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( @@ -421,7 +426,12 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): setattr(self, "async_setup_preview", async_setup_preview) @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index ac5166911ff..f7ceb4ab812 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -425,10 +425,20 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]): class ColorTempSelectorConfig(TypedDict, total=False): """Class to represent a color temp selector config.""" + unit: ColorTempSelectorUnit + min: int + max: int max_mireds: int min_mireds: int +class ColorTempSelectorUnit(StrEnum): + """Possible units for a color temperature selector.""" + + KELVIN = "kelvin" + MIRED = "mired" + + @SELECTORS.register("color_temp") class ColorTempSelector(Selector[ColorTempSelectorConfig]): """Selector of an color temperature.""" @@ -437,6 +447,11 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { + vol.Optional("unit", default=ColorTempSelectorUnit.MIRED): vol.All( + vol.Coerce(ColorTempSelectorUnit), lambda val: val.value + ), + vol.Optional("min"): vol.Coerce(int), + vol.Optional("max"): vol.Coerce(int), vol.Optional("max_mireds"): vol.Coerce(int), vol.Optional("min_mireds"): vol.Coerce(int), } @@ -448,11 +463,20 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): def __call__(self, data: Any) -> int: """Validate the passed selection.""" + range_min = self.config.get("min") + range_max = self.config.get("max") + + if not range_min: + range_min = self.config.get("min_mireds") + + if not range_max: + range_max = self.config.get("max_mireds") + value: int = vol.All( vol.Coerce(float), vol.Range( - min=self.config.get("min_mireds"), - max=self.config.get("max_mireds"), + min=range_min, + max=range_max, ), )(data) return value @@ -1182,6 +1206,7 @@ class TextSelectorConfig(TypedDict, total=False): suffix: str type: TextSelectorType autocomplete: str + multiple: bool class TextSelectorType(StrEnum): @@ -1219,6 +1244,7 @@ class TextSelector(Selector[TextSelectorConfig]): vol.Coerce(TextSelectorType), lambda val: val.value ), vol.Optional("autocomplete"): str, + vol.Optional("multiple", default=False): bool, } ) @@ -1226,10 +1252,14 @@ class TextSelector(Selector[TextSelectorConfig]): """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> str: + def __call__(self, data: Any) -> str | list[str]: """Validate the passed selection.""" - text: str = vol.Schema(str)(data) - return text + if not self.config["multiple"]: + text: str = vol.Schema(str)(data) + return text + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] class ThemeSelectorConfig(TypedDict): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 32f51a924f7..2ada25bd4cd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -42,7 +42,7 @@ from homeassistant.exceptions import ( UnknownUser, ) from homeassistant.loader import Integration, async_get_integrations, bind_hass -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( @@ -542,7 +542,9 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T try: return cast( JSON_TYPE, - _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + _SERVICES_SCHEMA( + load_yaml_dict(str(integration.file_path / "services.yaml")) + ), ) except FileNotFoundError: _LOGGER.warning( diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 721ac8bd5be..df8b1c1e019 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2125,6 +2125,10 @@ def to_json( option = ( ORJSON_PASSTHROUGH_OPTIONS + # OPT_NON_STR_KEYS is added as a workaround to + # ensure subclasses of str are allowed as dict keys + # See: https://github.com/ijl/orjson/issues/445 + | orjson.OPT_NON_STR_KEYS | (orjson.OPT_INDENT_2 if pretty_print else 0) | (orjson.OPT_SORT_KEYS if sort_keys else 0) ) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 41ad591d878..d6a31085cfb 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -48,7 +48,7 @@ def component_translation_path( If component is just a single file, will return None. """ parts = component.split(".") - domain = parts[-1] + domain = parts[0] is_platform = len(parts) == 2 # If it's a component that is just one file, we don't support translations @@ -57,7 +57,7 @@ def component_translation_path( return None if is_platform: - filename = f"{parts[0]}.{language}.json" + filename = f"{parts[1]}.{language}.json" else: filename = f"{language}.json" @@ -96,7 +96,7 @@ def _merge_resources( # Build response resources: dict[str, dict[str, Any]] = {} for component in components: - domain = component.partition(".")[0] + domain = component.rpartition(".")[-1] domain_resources = resources.setdefault(domain, {}) @@ -154,7 +154,7 @@ async def _async_get_component_strings( # Determine paths of missing components/platforms files_to_load = {} for loaded in components: - domain = loaded.rpartition(".")[-1] + domain = loaded.partition(".")[0] integration = integrations[domain] path = component_translation_path(loaded, language, integration) @@ -225,7 +225,7 @@ class _TranslationCache: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = list({loaded.partition(".")[0] for loaded in components}) ints_or_excs = await async_get_integrations(self.hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9ea82cb3e75..2f1373b61d9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,8 +3,7 @@ aiodiscover==1.5.1 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 -aiohttp==3.8.5;python_version<'3.12' -aiohttp==3.9.0;python_version>='3.12' +aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.2 @@ -16,19 +15,20 @@ bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.17.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 -dbus-fast==2.14.0 +dbus-fast==2.20.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 +habluetooth==0.10.0 hass-nabucasa==0.74.0 hassil==1.5.1 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.2 -home-assistant-intents==2023.11.17 +home-assistant-frontend==20231208.2 +home-assistant-intents==2023.12.05 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 @@ -57,8 +57,8 @@ ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 -yarl==1.9.2 -zeroconf==0.127.0 +yarl==1.9.4 +zeroconf==0.128.4 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 0e00d0b75f2..dcccdbccf40 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -32,7 +32,7 @@ REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), - "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), + "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), } diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 679042bc4e9..7a7f4323be6 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -263,7 +263,7 @@ async def _async_setup_component( if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS: continue async_notify_setup_error( - hass, platform_exception.platform_name, platform_exception.integration_link + hass, platform_exception.platform_path, platform_exception.integration_link ) if processed_config is None: log_error("Invalid config.") @@ -538,7 +538,7 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: if "." not in component: integrations.add(component) continue - domain, _, platform = component.partition(".") + platform, _, domain = component.partition(".") if domain in BASE_PLATFORMS: integrations.add(platform) return integrations @@ -563,7 +563,7 @@ def async_start_setup( time_taken = dt_util.utcnow() - started for unique, domain in unique_components.items(): del setup_started[unique] - integration = domain.rpartition(".")[-1] + integration = domain.partition(".")[0] if integration in setup_time: setup_time[integration] += time_taken else: diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 8e7fc3dc155..0ab4ac8c6c1 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -7,6 +7,8 @@ from typing import NamedTuple import attr +from .scaling import scale_to_ranged_value + class RGBColor(NamedTuple): """RGB hex values.""" @@ -576,6 +578,18 @@ def _white_levels_to_color_temperature( ), min(255, round(brightness * 255)) +def color_xy_to_temperature(x: float, y: float) -> int: + """Convert an xy color to a color temperature in Kelvin. + + Uses McCamy's approximation (https://doi.org/10.1002/col.5080170211), + close enough for uses between 2000 K and 10000 K. + """ + n = (x - 0.3320) / (0.1858 - y) + CCT = 437 * (n**3) + 3601 * (n**2) + 6861 * n + 5517 + + return int(CCT) + + def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """Clamp the given color component value between the given min and max values. @@ -732,3 +746,38 @@ def check_valid_gamut(Gamut: GamutType) -> bool: ) return not_on_line and red_valid and green_valid and blue_valid + + +def brightness_to_value(low_high_range: tuple[float, float], brightness: int) -> float: + """Given a brightness_scale convert a brightness to a single value. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 255: 100.0 + 127: ~49.8039 + 10: ~3.9216 + """ + return scale_to_ranged_value((1, 255), low_high_range, brightness) + + +def value_to_brightness(low_high_range: tuple[float, float], value: float) -> int: + """Given a brightness_scale convert a single value to a brightness. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 100: 255 + 50: 128 + 4: 10 + + The value will be clamped between 1..255 to ensure valid value. + """ + return min( + 255, + max(1, round(scale_to_ranged_value(low_high_range, (1, 255), value))), + ) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 34a81728d14..4859c5c85dd 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,9 +5,7 @@ import bisect from contextlib import suppress import datetime as dt from functools import partial -import platform import re -import time from typing import Any import zoneinfo @@ -16,7 +14,6 @@ import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.UTC DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC -CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 @@ -476,29 +473,3 @@ def _datetime_ambiguous(dattim: dt.datetime) -> bool: assert dattim.tzinfo is not None opposite_fold = dattim.replace(fold=not dattim.fold) return _datetime_exists(dattim) and dattim.utcoffset() != opposite_fold.utcoffset() - - -def __gen_monotonic_time_coarse() -> partial[float]: - """Return a function that provides monotonic time in seconds. - - This is the coarse version of time_monotonic, which is faster but less accurate. - - Since many arm64 and 32-bit platforms don't support VDSO with time.monotonic - because of errata, we can't rely on the kernel to provide a fast - monotonic time. - - https://lore.kernel.org/lkml/20170404171826.25030-1-marc.zyngier@arm.com/ - """ - # We use a partial here since its implementation is in native code - # which allows us to avoid the overhead of the global lookup - # of CLOCK_MONOTONIC_COARSE. - return partial(time.clock_gettime, CLOCK_MONOTONIC_COARSE) - - -monotonic_time_coarse = time.monotonic -with suppress(Exception): - if ( - platform.system() == "Linux" - and abs(time.monotonic() - __gen_monotonic_time_coarse()()) < 1 - ): - monotonic_time_coarse = __gen_monotonic_time_coarse() diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index ac18d43727c..1af35c604eb 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -33,9 +33,17 @@ class SerializationError(HomeAssistantError): """Error serializing the data to JSON.""" -json_loads: Callable[[bytes | bytearray | memoryview | str], JsonValueType] -json_loads = orjson.loads -"""Parse JSON data.""" +def json_loads(__obj: bytes | bytearray | memoryview | str) -> JsonValueType: + """Parse JSON data. + + This adds a workaround for orjson not handling subclasses of str, + https://github.com/ijl/orjson/issues/445. + """ + if type(__obj) in (bytes, bytearray, memoryview, str): + return orjson.loads(__obj) # type:ignore[no-any-return] + if isinstance(__obj, str): + return orjson.loads(str(__obj)) # type:ignore[no-any-return] + return orjson.loads(__obj) # type:ignore[no-any-return] def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayType: diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index ca5931b2670..cc4835022d3 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -3,6 +3,13 @@ from __future__ import annotations from typing import TypeVar +from .scaling import ( # noqa: F401 + int_states_in_range, + scale_ranged_value_to_int_range, + scale_to_ranged_value, + states_in_range, +) + _T = TypeVar("_T") @@ -69,8 +76,7 @@ def ranged_value_to_percentage( (1,255), 127: 50 (1,255), 10: 4 """ - offset = low_high_range[0] - 1 - return int(((value - offset) * 100) // states_in_range(low_high_range)) + return scale_ranged_value_to_int_range(low_high_range, (1, 100), value) def percentage_to_ranged_value( @@ -87,15 +93,4 @@ def percentage_to_ranged_value( (1,255), 50: 127.5 (1,255), 4: 10.2 """ - offset = low_high_range[0] - 1 - return states_in_range(low_high_range) * percentage / 100 + offset - - -def states_in_range(low_high_range: tuple[float, float]) -> float: - """Given a range of low and high values return how many states exist.""" - return low_high_range[1] - low_high_range[0] + 1 - - -def int_states_in_range(low_high_range: tuple[float, float]) -> int: - """Given a range of low and high values return how many integer states exist.""" - return int(states_in_range(low_high_range)) + return scale_to_ranged_value((1, 100), low_high_range, percentage) diff --git a/homeassistant/util/scaling.py b/homeassistant/util/scaling.py new file mode 100644 index 00000000000..70e2ac2516a --- /dev/null +++ b/homeassistant/util/scaling.py @@ -0,0 +1,62 @@ +"""Scaling util functions.""" +from __future__ import annotations + + +def scale_ranged_value_to_int_range( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> int: + """Given a range of low and high values convert a single value to another range. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), (1,100), 255: 100 + (1,255), (1,100), 127: 49 + (1,255), (1,100), 10: 3 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return int( + (value - source_offset) + * states_in_range(target_low_high_range) + // states_in_range(source_low_high_range) + + target_offset + ) + + +def scale_to_ranged_value( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> float: + """Given a range of low and high values convert a single value to another range. + + Do not include 0 in a range if 0 means off, + e.g. for brightness or fan speed. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), 255: 100 + (1,255), 127: ~49.8039 + (1,255), 10: ~3.9216 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return (value - source_offset) * ( + states_in_range(target_low_high_range) + ) / states_in_range(source_low_high_range) + target_offset + + +def states_in_range(low_high_range: tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 2b503716063..6bfbec88a33 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -61,16 +61,11 @@ SSL_CIPHER_LISTS = { @cache -def create_no_verify_ssl_context( - ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, -) -> ssl.SSLContext: - """Return an SSL context that does not verify the server certificate. +def _create_no_verify_ssl_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: + # This is a copy of aiohttp's create_default_context() function, with the + # ssl verify turned off. + # https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - This is a copy of aiohttp's create_default_context() function, with the - ssl verify turned off. - - https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - """ sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) sslcontext.check_hostname = False sslcontext.verify_mode = ssl.CERT_NONE @@ -84,12 +79,16 @@ def create_no_verify_ssl_context( return sslcontext -@cache -def client_context( +def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: - """Return an SSL context for making requests.""" + """Return an SSL context that does not verify the server certificate.""" + return _create_no_verify_ssl_context(ssl_cipher_list=ssl_cipher_list) + + +@cache +def _client_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -104,6 +103,14 @@ def client_context( return sslcontext +def client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an SSL context for making requests.""" + + return _client_context(ssl_cipher_list=ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() _DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index b3f1b7ecd43..fe4f01677cd 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -2,7 +2,14 @@ from .const import SECRET_YAML from .dumper import dump, save_yaml from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import Secrets, load_yaml, parse_yaml, secret_yaml +from .loader import ( + Secrets, + YamlTypeError, + load_yaml, + load_yaml_dict, + parse_yaml, + secret_yaml, +) from .objects import Input __all__ = [ @@ -11,7 +18,9 @@ __all__ = [ "dump", "save_yaml", "Secrets", + "YamlTypeError", "load_yaml", + "load_yaml_dict", "secret_yaml", "parse_yaml", "UndefinedSubstitution", diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index fbffae448b2..60e917a6a99 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,7 @@ """Custom loader.""" from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import suppress import fnmatch from io import StringIO, TextIOWrapper @@ -23,6 +23,7 @@ except ImportError: ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -35,6 +36,10 @@ _DictT = TypeVar("_DictT", bound=dict) _LOGGER = logging.getLogger(__name__) +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + class Secrets: """Store secrets while loading YAML.""" @@ -136,6 +141,37 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLoader(FastSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLoader.__report_deprecated() + FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + + class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -145,10 +181,41 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets +class SafeLineLoader(PythonSafeLoader): + """Provided for backwards compatibility. Logs when instantiated.""" + + def __init__(*args: Any, **kwargs: Any) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.__init__(*args, **kwargs) + + @classmethod + def add_constructor(cls, tag: str, constructor: Callable) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_constructor(tag, constructor) + + @classmethod + def add_multi_constructor( + cls, tag_prefix: str, multi_constructor: Callable + ) -> None: + """Log a warning and call super.""" + SafeLineLoader.__report_deprecated() + PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) + + @staticmethod + def __report_deprecated() -> None: + """Log deprecation warning.""" + report( + "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " + "which will stop working in HA Core 2024.6," + ) + + LoaderType = FastSafeLoader | PythonSafeLoader -def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: +def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: @@ -158,6 +225,20 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc +def load_yaml_dict(fname: str, secrets: Secrets | None = None) -> dict: + """Load a YAML file and ensure the top level is a dict. + + Raise if the top level is not a dict. + Return an empty dict if the file is empty. + """ + loaded_yaml = load_yaml(fname, secrets) + if loaded_yaml is None: + loaded_yaml = {} + if not isinstance(loaded_yaml, dict): + raise YamlTypeError(f"YAML file {fname} does not contain a dict") + return loaded_yaml + + def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: @@ -192,12 +273,7 @@ def _parse_yaml( secrets: Secrets | None = None, ) -> JSON_TYPE: """Load a YAML file.""" - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - or NodeDictClass() - ) + return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] @overload @@ -246,7 +322,10 @@ def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """ fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: - return _add_reference(load_yaml(fname, loader.secrets), loader, node) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + loaded_yaml = NodeDictClass() + return _add_reference(loaded_yaml, loader, node) except FileNotFoundError as exc: raise HomeAssistantError( f"{node.start_mark}: Unable to read file {fname}." @@ -276,7 +355,10 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - mapping[filename] = load_yaml(fname, loader.secrets) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + continue + mapping[filename] = loaded_yaml return _add_reference(mapping, loader, node) @@ -301,9 +383,10 @@ def _include_dir_list_yaml( """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ - load_yaml(f, loader.secrets) + loaded_yaml for f in _find_files(loc, "*.yaml") if os.path.basename(f) != SECRET_YAML + and (loaded_yaml := load_yaml(f, loader.secrets)) is not None ] diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index b2320a74d2c..6aedc85cf60 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,7 +2,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +import voluptuous as vol +from voluptuous.schema_builder import _compile_scalar import yaml @@ -13,6 +16,10 @@ class NodeListClass(list): class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" + def __voluptuous_compile__(self, schema: vol.Schema) -> Any: + """Needed because vol.Schema.compile does not handle str subclasses.""" + return _compile_scalar(self) + class NodeDictClass(dict): """Wrapper class to be able to add attributes on a dict.""" diff --git a/mypy.ini b/mypy.ini index 0ed06edaa1d..2a3a5f0fb0f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -961,6 +961,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.faa_delays.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fan.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1031,6 +1041,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.flexit_bacnet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.flux_led.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1261,6 +1281,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.holiday.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homeassistant.exposed_entities] check_untyped_defs = true disallow_incomplete_defs = true @@ -2401,6 +2431,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pushbullet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.pvoutput.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2892,6 +2932,16 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.streamlabswater.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sun.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index b05f5f5f9dc..9e9e8de4916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.0.dev0" +version = "2024.1.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -23,8 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.0;python_version>='3.12'", - "aiohttp==3.8.5;python_version<'3.12'", + "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", "aiohttp-zlib-ng==0.1.1", @@ -57,7 +56,7 @@ dependencies = [ "ulid-transform==0.9.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.9.2", + "yarl==1.9.4", ] [project.urls] @@ -456,9 +455,6 @@ filterwarnings = [ "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", # https://github.com/kiorky/croniter/issues/49 - v1.4.1 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", - # https://github.com/spulec/freezegun/issues/508 - v1.2.2 - # https://github.com/spulec/freezegun/pull/511 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:freezegun.api", # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 diff --git a/requirements.txt b/requirements.txt index f26c1a84e81..1b5b8d63c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.0;python_version>='3.12' -aiohttp==3.8.5;python_version<'3.12' +aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.1.1 @@ -33,4 +32,4 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.9.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.9.2 +yarl==1.9.4 diff --git a/requirements_all.txt b/requirements_all.txt index 64349902be8..97e0f899ad5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -45,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -185,7 +185,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 @@ -196,6 +196,9 @@ aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -212,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 @@ -236,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.0 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -353,7 +356,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -374,13 +377,13 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi aiowaqi==3.0.1 @@ -433,11 +436,8 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 @@ -504,6 +504,9 @@ azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.baidu baidu-aip==1.6.6 @@ -523,10 +526,10 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -564,7 +567,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 @@ -655,7 +658,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -723,7 +726,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.1.0 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -756,7 +759,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -789,7 +792,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.6 +evohome-async==0.4.15 # homeassistant.components.faa_delays faadelays==2023.9.1 @@ -822,6 +825,9 @@ fixerio==1.0.0a0 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 @@ -977,6 +983,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.10.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 @@ -1010,14 +1019,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231208.2 # homeassistant.components.conversation -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1054,7 +1064,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 @@ -1128,9 +1138,6 @@ kiwiki-client==0.1.1 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 @@ -1237,7 +1244,7 @@ messagebird==1.2.0 meteoalertapi==0.3.0 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -1249,7 +1256,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 @@ -1413,7 +1420,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1479,7 +1486,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.3 +plugwise==0.34.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1527,6 +1534,9 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 @@ -1835,7 +1845,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 @@ -1862,7 +1872,7 @@ pylgnetcast==0.3.7 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.0 # homeassistant.components.litterrobot pylitterbot==2023.4.9 @@ -1922,7 +1932,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 @@ -1948,6 +1958,9 @@ pyopnsense==0.4.0 # homeassistant.components.opple pyoppleio-legacy==1.0.8 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -2029,7 +2042,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -2088,7 +2101,7 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 @@ -2169,7 +2182,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2184,7 +2197,7 @@ python-mystrom==2.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -2248,7 +2261,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2338,10 +2351,10 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2404,7 +2417,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2522,7 +2535,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.4.2 +stookwijzer==1.3.0 # homeassistant.components.streamlabswater streamlabswater==1.0.1 @@ -2539,6 +2552,9 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.0 + # homeassistant.components.surepetcare surepy==0.8.0 @@ -2587,6 +2603,9 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.tensorflow # tf-models-official==2.5.0 @@ -2594,7 +2613,7 @@ tesla-wall-connector==1.0.2 thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.thermoworks_smoke thermoworks-smoke==0.1.8 @@ -2660,7 +2679,7 @@ unifi-discovery==1.1.7 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -2753,7 +2772,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2795,7 +2814,7 @@ yeelight==0.7.14 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 @@ -2807,19 +2826,19 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2828,19 +2847,19 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test.txt b/requirements_test.txt index d880fecaca5..f8918dc73f4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ -r requirements_test_pre_commit.txt astroid==3.0.1 coverage==7.3.2 -freezegun==1.2.2 +freezegun==1.3.1 mock-open==1.4.0 mypy==1.7.1 pre-commit==3.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6be6f60474e..184f0eb8cb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ DoorBirdPy==2.1.0 HAP-python==4.9.1 # homeassistant.components.tasmota -HATasmota==0.7.3 +HATasmota==0.8.0 # homeassistant.components.doods # homeassistant.components.generic @@ -39,7 +39,7 @@ HATasmota==0.7.3 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.6 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -164,7 +164,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.2.4 +aioairq==0.3.1 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 @@ -175,6 +175,9 @@ aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -191,7 +194,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.5.2 +aiocomelit==0.6.2 # homeassistant.components.dhcp aiodiscover==1.5.1 @@ -215,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.0 +aioesphomeapi==19.3.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -326,7 +329,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -347,13 +350,13 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==66 +aiounifi==67 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.2 +aiovodafone==0.4.3 # homeassistant.components.waqi aiowaqi==3.0.1 @@ -397,11 +400,8 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit -apple_weatherkit==1.0.4 +apple_weatherkit==1.1.2 # homeassistant.components.apprise apprise==1.6.0 @@ -438,6 +438,9 @@ axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.homekit base36==0.1.1 @@ -445,10 +448,10 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.8 +bellows==0.37.1 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.3 +bimmer-connected[china]==0.14.6 # homeassistant.components.bluetooth bleak-retry-connector==3.3.0 @@ -476,7 +479,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.17.0 # homeassistant.components.bond bond-async==0.2.1 @@ -536,7 +539,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.20.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -589,7 +592,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 @@ -610,7 +613,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -651,6 +654,9 @@ fivem-api==0.1.2 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 @@ -776,6 +782,9 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==0.10.0 + # homeassistant.components.cloud hass-nabucasa==0.74.0 @@ -797,14 +806,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.36 +holidays==0.37 # homeassistant.components.frontend -home-assistant-frontend==20231030.2 +home-assistant-frontend==20231208.2 # homeassistant.components.conversation -home-assistant-intents==2023.11.17 +home-assistant-intents==2023.12.05 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -832,7 +842,7 @@ ibeacon-ble==1.0.1 # homeassistant.components.local_calendar # homeassistant.components.local_todo -ical==6.1.0 +ical==6.1.1 # homeassistant.components.ping icmplib==3.0 @@ -885,9 +895,6 @@ kegtron-ble==0.4.0 # homeassistant.components.knx knx-frontend==2023.6.23.191712 -# homeassistant.components.komfovent -komfovent-api==0.0.3 - # homeassistant.components.konnected konnected==1.2.0 @@ -961,7 +968,7 @@ medcom-ble==0.1.1 melnor-bluetooth==0.0.25 # homeassistant.components.meteo_france -meteofrance-api==1.2.0 +meteofrance-api==1.3.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -973,7 +980,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.6 +millheater==0.11.7 # homeassistant.components.minio minio==7.1.12 @@ -1089,7 +1096,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.39 +opower==0.0.40 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1137,7 +1144,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.34.3 +plugwise==0.34.5 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1170,6 +1177,9 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 @@ -1388,7 +1398,7 @@ pykmtronic==0.3.0 pykodi==0.2.7 # homeassistant.components.kostal_plenticore -pykoplenti==1.0.0 +pykoplenti==1.2.2 # homeassistant.components.kraken pykrakenapi==0.1.8 @@ -1406,7 +1416,7 @@ pylaunches==1.4.0 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.0 # homeassistant.components.litterrobot pylitterbot==2023.4.9 @@ -1451,7 +1461,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.5.1 +pynws==1.6.0 # homeassistant.components.nx584 pynx584==0.5 @@ -1471,6 +1481,9 @@ pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.4.0 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -1534,7 +1547,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.11.0 +pyschlage==2023.12.0 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -1587,7 +1600,7 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.switchbee pyswitchbee==1.8.0 @@ -1623,7 +1636,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==4.0.2 +python-matter-server==5.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1632,7 +1645,7 @@ python-miio==0.5.12 python-mystrom==2.2.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -1684,7 +1697,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.21.0 +pyunifiprotect==4.22.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1750,10 +1763,10 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.1 +reolink-aio==0.8.2 # homeassistant.components.rflink rflink==0.0.65 @@ -1792,7 +1805,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 @@ -1886,7 +1899,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.4.2 +stookwijzer==1.3.0 # homeassistant.components.huawei_lte # homeassistant.components.solaredge @@ -1900,6 +1913,9 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.0 + # homeassistant.components.surepetcare surepy==0.8.0 @@ -1927,11 +1943,14 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.thermobeacon thermobeacon-ble==0.6.0 # homeassistant.components.thermopro -thermopro-ble==0.4.5 +thermopro-ble==0.5.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 @@ -1979,7 +1998,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.7 # homeassistant.components.zha -universal-silabs-flasher==0.0.14 +universal-silabs-flasher==0.0.15 # homeassistant.components.upb upb-lib==0.5.4 @@ -2057,7 +2076,7 @@ wled==0.17.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==1.2.0 +wyoming==1.4.0 # homeassistant.components.xbox xbox-webapi==2.0.11 @@ -2093,7 +2112,7 @@ yalexs==1.10.0 yeelight==0.7.14 # homeassistant.components.yolink -yolink-api==0.3.1 +yolink-api==0.3.4 # homeassistant.components.youless youless-api==1.0.1 @@ -2105,31 +2124,31 @@ youtubeaio==1.1.5 yt-dlp==2023.11.16 # homeassistant.components.zamg -zamg==0.3.0 +zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.127.0 +zeroconf==0.128.4 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.106 +zha-quirks==0.0.107 # homeassistant.components.zha -zigpy-deconz==0.21.1 +zigpy-deconz==0.22.0 # homeassistant.components.zha -zigpy-xbee==0.19.0 +zigpy-xbee==0.20.0 # homeassistant.components.zha -zigpy-zigate==0.11.0 +zigpy-zigate==0.12.0 # homeassistant.components.zha -zigpy-znp==0.11.6 +zigpy-znp==0.12.0 # homeassistant.components.zha -zigpy==0.59.0 +zigpy==0.60.0 # homeassistant.components.zwave_js zwave-js-server-python==0.54.0 diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 32803731ecd..c454c69d141 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -16,6 +16,7 @@ from . import ( coverage, dependencies, dhcp, + docker, json, manifest, metadata, @@ -50,6 +51,7 @@ INTEGRATION_PLUGINS = [ ] HASS_PLUGINS = [ coverage, + docker, mypy_config, metadata, ] diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py new file mode 100644 index 00000000000..c9d81424229 --- /dev/null +++ b/script/hassfest/docker.py @@ -0,0 +1,90 @@ +"""Generate and validate the dockerfile.""" +from homeassistant import core +from homeassistant.util import executor, thread + +from .model import Config, Integration + +DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +ARG BUILD_FROM +FROM ${{BUILD_FROM}} + +# Synchronize with homeassistant/core.py:async_stop +ENV \ + S6_SERVICES_GRACETIME={timeout} + +ARG QEMU_CPU + +WORKDIR /usr/src + +## Setup Home Assistant Core dependencies +COPY requirements.txt homeassistant/ +COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ +RUN \ + pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements.txt + +COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ +RUN \ + if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_frontend-*.whl; \ + fi \ + && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ + pip3 install homeassistant/home_assistant_intents-*.whl; \ + fi \ + && \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ + MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ + pip3 install \ + --only-binary=:all: \ + -r homeassistant/requirements_all.txt + +## Setup Home Assistant Core +COPY . homeassistant/ +RUN \ + pip3 install \ + --only-binary=:all: \ + -e ./homeassistant \ + && python3 -m compileall \ + homeassistant/homeassistant + +# Home Assistant S6-Overlay +COPY rootfs / + +WORKDIR /config +""" + + +def _generate_dockerfile() -> str: + timeout = ( + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + + executor.EXECUTOR_SHUTDOWN_TIMEOUT + + thread.THREADING_SHUTDOWN_TIMEOUT + + 10 + ) + return DOCKERFILE_TEMPLATE.format(timeout=timeout * 1000) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate dockerfile.""" + dockerfile_content = _generate_dockerfile() + config.cache["dockerfile"] = dockerfile_content + + dockerfile_path = config.root / "Dockerfile" + if dockerfile_path.read_text() != dockerfile_content: + config.add_error( + "docker", + "File Dockerfile is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate dockerfile.""" + dockerfile_path = config.root / "Dockerfile" + dockerfile_path.write_text(config.cache["dockerfile"]) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 4a826f7cad9..580294705cf 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -13,7 +13,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, selector, service -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration @@ -107,7 +107,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool def validate_services(config: Config, integration: Integration) -> None: """Validate services.""" try: - data = load_yaml(str(integration.path / "services.yaml")) + data = load_yaml_dict(str(integration.path / "services.yaml")) except FileNotFoundError: # Find if integration uses services has_services = grep_dir( @@ -122,7 +122,7 @@ def validate_services(config: Config, integration: Integration) -> None: ) return except HomeAssistantError: - integration.add_error("services", "Unable to load services.yaml") + integration.add_error("services", "Invalid services.yaml") return try: diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 3dd60b51296..caef6c2e729 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -7,6 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -18,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) # TODO adjust the data schema to the data that you need STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("username"): str, - vol.Required("password"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -50,12 +51,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, # If your PyPI package is not built with async, pass your methods # to the executor: # await hass.async_add_executor_job( - # your_validate_func, data["username"], data["password"] + # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] # ) - hub = PlaceholderHub(data["host"]) + hub = PlaceholderHub(data[CONF_HOST]) - if not await hub.authenticate(data["username"], data["password"]): + if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): raise InvalidAuth # If you cannot connect: diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index cbc1449378c..bb9e6380cdc 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,16 +1,13 @@ """Test the NEW_NAME config flow.""" from unittest.mock import AsyncMock, patch -import pytest - from homeassistant import config_entries from homeassistant.components.NEW_DOMAIN.config_flow import CannotConnect, InvalidAuth from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -18,33 +15,35 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Name of the device" - assert result2["data"] == { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", } assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -54,20 +53,48 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -77,14 +104,41 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index ef7beab488b..9e9b48a07f6 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -894,10 +894,7 @@ async def test_auth_module_expired_session(mock_hass) -> None: assert step["type"] == data_entry_flow.FlowResultType.FORM assert step["step_id"] == "mfa" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION, - ): + with freeze_time(dt_util.utcnow() + MFA_SESSION_EXPIRATION): step = await manager.login_flow.async_configure( step["flow_id"], {"pin": "test-pin"} ) diff --git a/tests/common.py b/tests/common.py index a4979c85853..15498019b16 100644 --- a/tests/common.py +++ b/tests/common.py @@ -297,6 +297,7 @@ def async_mock_service( schema: vol.Schema | None = None, response: ServiceResponse = None, supports_response: SupportsResponse | None = None, + raise_exception: Exception | None = None, ) -> list[ServiceCall]: """Set up a fake service & return a calls log list to this service.""" calls = [] @@ -305,6 +306,8 @@ def async_mock_service( def mock_service_log(call): # pylint: disable=unnecessary-lambda """Mock service call.""" calls.append(call) + if raise_exception is not None: + raise raise_exception return response if supports_response is None: @@ -1424,7 +1427,7 @@ ANY = _HA_ANY() def raise_contains_mocks(val: Any) -> None: """Raise for mocks.""" if isinstance(val, Mock): - raise TypeError + raise TypeError(val) if isinstance(val, dict): for dict_value in val.values(): diff --git a/tests/components/advantage_air/__init__.py b/tests/components/advantage_air/__init__.py index b826e3ac7ce..05d98e957bb 100644 --- a/tests/components/advantage_air/__init__.py +++ b/tests/components/advantage_air/__init__.py @@ -1,12 +1,14 @@ """Tests for the Advantage Air component.""" +from unittest.mock import AsyncMock, patch + from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture -TEST_SYSTEM_DATA = load_fixture("advantage_air/getSystemData.json") -TEST_SET_RESPONSE = load_fixture("advantage_air/setAircon.json") +TEST_SYSTEM_DATA = load_json_object_fixture("getSystemData.json", DOMAIN) +TEST_SET_RESPONSE = None USER_INPUT = { CONF_IP_ADDRESS: "1.2.3.4", @@ -25,6 +27,22 @@ TEST_SET_THING_URL = ( ) +def patch_get(return_value=TEST_SYSTEM_DATA, side_effect=None): + """Patch the Advantage Air async_get method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air.async_get", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + +def patch_update(return_value=True, side_effect=None): + """Patch the Advantage Air async_set method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air._endpoint.async_update", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + async def add_mock_config(hass): """Create a fake Advantage Air Config Entry.""" entry = MockConfigEntry( @@ -33,6 +51,7 @@ async def add_mock_config(hass): unique_id="0123456", data=USER_INPUT, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/advantage_air/conftest.py b/tests/components/advantage_air/conftest.py new file mode 100644 index 00000000000..9da0a176309 --- /dev/null +++ b/tests/components/advantage_air/conftest.py @@ -0,0 +1,20 @@ +"""Fixtures for advantage_air.""" +from __future__ import annotations + +import pytest + +from . import patch_get, patch_update + + +@pytest.fixture +def mock_get(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_get() as mock_get: + yield mock_get + + +@pytest.fixture +def mock_update(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_update() as mock_get: + yield mock_get diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index c6d055f396a..19b0dba2eda 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Advantage Air Binary Sensor Platform.""" from datetime import timedelta +from unittest.mock import AsyncMock from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON @@ -7,37 +8,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensor_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test binary sensor setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First Air Filter entity_id = "binary_sensor.myzone_filter" state = hass.states.get(entity_id) @@ -83,6 +67,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -91,6 +76,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state @@ -105,6 +91,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -113,6 +100,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index a1eb886cbd0..ba97644501f 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -1,20 +1,10 @@ """Test the Advantage Air Climate Platform.""" -from json import loads +from unittest.mock import AsyncMock + +from advantage_air import ApiError import pytest -from homeassistant.components.advantage_air.climate import ( - ADVANTAGE_AIR_COOL_TARGET, - ADVANTAGE_AIR_HEAT_TARGET, - HASS_FAN_MODES, - HASS_HVAC_MODES, -) -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -37,35 +27,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_update -async def test_climate_async_setup_entry( +async def test_climate_myzone_main( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: - """Test climate platform.""" + """Test climate platform main entity.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - # Test MyZone Climate Entity + # Test MyZone main climate entity entity_id = "climate.myzone" state = hass.states.get(entity_id) assert state @@ -80,19 +55,24 @@ async def test_climate_async_setup_entry( assert entry.unique_id == "uniqueid-ac1" # Test setting HVAC Mode + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_update.assert_called_once() + mock_update.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert data["ac1"]["info"]["mode"] == HASS_HVAC_MODES[HVACMode.FAN_ONLY] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off with HVAC Mode await hass.services.async_call( @@ -101,26 +81,17 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() - # Test changing Fan Mode await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["fan"] == HASS_FAN_MODES[FAN_LOW] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test changing Temperature await hass.services.async_call( @@ -129,12 +100,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["setTemp"] == 25 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning On await hass.services.async_call( @@ -143,12 +110,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off await hass.services.async_call( @@ -157,12 +120,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() + + +async def test_climate_myzone_zone( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform myzone zone entity.""" + + await add_mock_config(hass) # Test Climate Zone Entity entity_id = "climate.myzone_zone_open_with_sensor" @@ -184,14 +154,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Climate Zone Off await hass.services.async_call( @@ -200,13 +164,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, @@ -214,11 +173,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) + mock_update.assert_called_once() + mock_update.reset_mock() - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + +async def test_climate_myauto_main( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform zone entity.""" + + await add_mock_config(hass) # Test MyAuto Climate Entity entity_id = "climate.myauto" @@ -231,44 +198,34 @@ async def test_climate_async_setup_entry( assert entry assert entry.unique_id == "uniqueid-ac3" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: [entity_id], - ATTR_TARGET_TEMP_LOW: 21, - ATTR_TARGET_TEMP_HIGH: 23, - }, - blocking=True, - ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["info"][ADVANTAGE_AIR_HEAT_TARGET] == 21 - assert data["ac3"]["info"][ADVANTAGE_AIR_COOL_TARGET] == 23 + with patch_update() as mock_update: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_LOW: 21, + ATTR_TARGET_TEMP_HIGH: 23, + }, + blocking=True, + ) + mock_update.assert_called_once() async def test_climate_async_failed_update( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test climate change failure.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - exc=SyntaxError, - ) - await add_mock_config(hass) - with pytest.raises(HomeAssistantError): + mock_update.side_effect = ApiError + await add_mock_config(hass) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ["climate.myzone"], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/setAircon" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_config_flow.py b/tests/components/advantage_air/test_config_flow.py index fc74df5538b..64d445a0b20 100644 --- a/tests/components/advantage_air/test_config_flow.py +++ b/tests/components/advantage_air/test_config_flow.py @@ -1,23 +1,18 @@ """Test the Advantage Air config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from advantage_air import ApiError from homeassistant import config_entries, data_entry_flow from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, USER_INPUT - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import TEST_SYSTEM_DATA, USER_INPUT -async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_form(hass: HomeAssistant) -> None: """Test that form shows up.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -26,6 +21,9 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert result1["errors"] == {} with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get, patch( "homeassistant.components.advantage_air.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -34,43 +32,44 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> USER_INPUT, ) await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + mock_get.assert_called_once() - assert len(aioclient_mock.mock_calls) == 1 assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "testname" assert result2["data"] == USER_INPUT - assert len(mock_setup_entry.mock_calls) == 1 # Test Duplicate Config Flow result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + USER_INPUT, + ) assert result4["type"] == data_entry_flow.FlowResultType.ABORT -async def test_form_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(side_effect=ApiError), + ) as mock_get: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + mock_get.assert_called_once() assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} - assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index af516d16e6e..8166b5da941 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -1,10 +1,6 @@ """Test the Advantage Air Cover Platform.""" -from json import loads +from unittest.mock import AsyncMock -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -17,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OPEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_ac_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Cover Zone Entity @@ -65,12 +44,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -78,13 +53,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z01"]["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -92,12 +62,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 50}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -105,12 +71,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 0}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test controlling multiple Cover Zone Entity await hass.services.async_call( @@ -124,9 +86,9 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert len(mock_update.mock_calls) == 2 + mock_update.reset_mock() + await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -138,27 +100,18 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN + + assert len(mock_update.mock_calls) == 2 async def test_things_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Blind 1 Entity @@ -171,7 +124,7 @@ async def test_things_cover( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-200" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( COVER_DOMAIN, @@ -179,13 +132,8 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -193,10 +141,4 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_diagnostics.py b/tests/components/advantage_air/test_diagnostics.py index 01f6d809a49..80de9019715 100644 --- a/tests/components/advantage_air/test_diagnostics.py +++ b/tests/components/advantage_air/test_diagnostics.py @@ -1,28 +1,24 @@ """Test the Advantage Air Diagnostics.""" +from unittest.mock import AsyncMock + from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator async def test_select_async_setup_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, snapshot: SnapshotAssertion, + mock_get: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/advantage_air/test_init.py b/tests/components/advantage_air/test_init.py index c665d038878..21cadbc4b3d 100644 --- a/tests/components/advantage_air/test_init.py +++ b/tests/components/advantage_air/test_init.py @@ -1,22 +1,17 @@ """Test the Advantage Air Initialization.""" +from unittest.mock import AsyncMock + +from advantage_air import ApiError + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_get -async def test_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry(hass: HomeAssistant, mock_get: AsyncMock) -> None: """Test a successful setup entry and unload.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.LOADED @@ -25,15 +20,9 @@ async def test_async_setup_entry( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_async_setup_entry_failure( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry_failure(hass: HomeAssistant) -> None: """Test a unsuccessful setup entry.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - - entry = await add_mock_config(hass) + with patch_get(side_effect=ApiError): + entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index 0e27b8aec73..4d21781772d 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -15,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_LIGHT_URL, - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test light setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_LIGHT_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Light Entity @@ -62,13 +43,9 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -76,13 +53,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Dimmable Light Entity entity_id = "light.light_b" @@ -98,13 +70,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -112,32 +79,17 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things lights.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -149,7 +101,7 @@ async def test_things_light( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-204" + assert entry.unique_id == f"uniqueid-{light_id}" await hass.services.async_call( LIGHT_DOMAIN, @@ -157,13 +109,8 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -171,10 +118,4 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 553c2e60180..3367595d777 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -1,5 +1,7 @@ """Test the Advantage Air Select Platform.""" -from json import loads + + +from unittest.mock import AsyncMock from homeassistant.components.select import ( ATTR_OPTION, @@ -10,37 +12,19 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_select_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test MyZone Select Entity entity_id = "select.myzone_myzone" state = hass.states.get(entity_id) @@ -57,10 +41,4 @@ async def test_select_async_setup_entry( {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Zone 3"}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["myZone"] == 3 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index e4fab12291d..a7483e680b3 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,6 +1,6 @@ """Test the Advantage Air Sensor Platform.""" from datetime import timedelta -from json import loads +from unittest.mock import AsyncMock from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( @@ -13,37 +13,21 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test sensor platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First TimeToOn Sensor entity_id = "sensor.myzone_time_to_on" state = hass.states.get(entity_id) @@ -55,19 +39,15 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-timetoOn" value = 20 + await hass.services.async_call( ADVANTAGE_AIR_DOMAIN, ADVANTAGE_AIR_SERVICE_SET_TIME_TO, {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOn"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First TimeToOff Sensor entity_id = "sensor.myzone_time_to_off" @@ -86,13 +66,8 @@ async def test_sensor_platform( {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOff"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First Zone Vent Sensor entity_id = "sensor.myzone_zone_open_with_sensor_vent" @@ -139,6 +114,7 @@ async def test_sensor_platform( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -147,6 +123,7 @@ async def test_sensor_platform( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 99e4c645e71..a703f7edefd 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -14,34 +12,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_cover_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test switch platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -60,12 +41,8 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -73,30 +50,17 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_switch( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things switches.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -108,7 +72,7 @@ async def test_things_switch( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-205" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( SWITCH_DOMAIN, @@ -116,13 +80,8 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -130,10 +89,4 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_update.py b/tests/components/advantage_air/test_update.py index 985641b923b..cb180d73f39 100644 --- a/tests/components/advantage_air/test_update.py +++ b/tests/components/advantage_air/test_update.py @@ -1,25 +1,26 @@ """Test the Advantage Air Update Platform.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import load_json_object_fixture + +TEST_NEEDS_UPDATE = load_json_object_fixture("needsUpdate.json", DOMAIN) async def test_update_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test update platform.""" - - aioclient_mock.get( - TEST_SYSTEM_URL, - text=load_fixture("advantage_air/needsUpdate.json"), - ) + mock_get.return_value = TEST_NEEDS_UPDATE await add_mock_config(hass) entity_id = "update.testname_app" diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 252c12f80fa..52fc8d2300b 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" from unittest.mock import patch -from aioairq.core import DeviceInfo, InvalidAuth, InvalidInput +from aioairq import DeviceInfo, InvalidAuth, InvalidInput from aiohttp.client_exceptions import ClientConnectionError import pytest diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 7a1abe96110..0a5b1f79f72 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera -from homeassistant.components.cover import CoverDeviceClass +from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config @@ -1884,8 +1884,199 @@ async def test_group(hass: HomeAssistant) -> None: ) -async def test_cover_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("position", "position_attr_in_service_call", "supported_features", "service_call"), + [ + ( + 30, + 30, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.close_cover", + ), + ( + 99, + 99, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + "cover.open_cover", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION, + "cover.set_cover_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN, + "cover.set_cover_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_POSITION | CoverEntityFeature.CLOSE, + "cover.set_cover_position", + ), + ], + ids=[ + "position_30_open_close", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_cover_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and position using rangeController.""" + device = ( + "cover.test_range", + "open", + { + "friendly_name": "Test cover range", + "device_class": "blind", + "supported_features": supported_features, + "position": position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Position", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "cover#test_range", + service_call, + hass, + payload={"rangeValue": position}, + instance="cover.position", + ) + assert call.data.get("position") == position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == position + + +async def test_cover_position_range( + hass: HomeAssistant, +) -> None: + """Test cover discovery and position range using rangeController. + + Also tests an invalid cover position being handled correctly. + """ + device = ( "cover.test_range", "open", @@ -1969,59 +2160,6 @@ async def test_cover_position_range(hass: HomeAssistant) -> None: "range": {"minimumValue": 1, "maximumValue": 100}, } in position_state_mappings - call, _ = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.set_cover_position", - hass, - payload={"rangeValue": 50}, - instance="cover.position", - ) - assert call.data["position"] == 50 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.close_cover", - hass, - payload={"rangeValue": 0}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValue": 100}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_range", - "cover.open_cover", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.position", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "AdjustRangeValue", @@ -3435,8 +3573,159 @@ async def test_presence_sensor(hass: HomeAssistant) -> None: assert {"name": "humanPresenceDetectionState"} in properties["supported"] -async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "tilt_position", + "tilt_position_attr_in_service_call", + "supported_features", + "service_call", + ), + [ + ( + 30, + 30, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 0, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.close_cover_tilt", + ), + ( + 99, + 99, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + None, + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT, + "cover.open_cover_tilt", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 60, + 60, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION, + "cover.set_cover_tilt_position", + ), + ( + 0, + 0, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT, + "cover.set_cover_tilt_position", + ), + ( + 100, + 100, + CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT, + "cover.set_cover_tilt_position", + ), + ], + ids=[ + "tilt_position_30_open_close", + "tilt_position_0_open_close", + "tilt_position_99_open_close", + "tilt_position_100_open_close", + "tilt_position_0_no_open_close", + "tilt_position_60_no_open_close", + "tilt_position_100_no_open_close", + "tilt_position_0_no_close", + "tilt_position_100_no_open", + ], +) +async def test_cover_tilt_position( + hass: HomeAssistant, + tilt_position: int, + tilt_position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, +) -> None: """Test cover discovery and tilt position using rangeController.""" + device = ( + "cover.test_tilt_range", + "open", + { + "friendly_name": "Test cover tilt range", + "device_class": "blind", + "supported_features": supported_features, + "tilt_position": tilt_position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "cover#test_tilt_range" + assert appliance["displayCategories"][0] == "INTERIOR_BLIND" + assert appliance["friendlyName"] == "Test cover tilt range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "cover.tilt" + + semantics = range_capability["semantics"] + assert semantics is not None + + action_mappings = semantics["actionMappings"] + assert action_mappings is not None + + state_mappings = semantics["stateMappings"] + assert state_mappings is not None + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "cover#test_tilt_range", + service_call, + hass, + payload={"rangeValue": tilt_position}, + instance="cover.tilt", + ) + assert call.data.get("tilt_position") == tilt_position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == tilt_position + + +async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: + """Test cover discovery and tilt position range using rangeController. + + Also tests and invalid tilt position being handled correctly. + """ device = ( "cover.test_tilt_range", "open", @@ -3485,48 +3774,6 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None: ) assert call.data["tilt_position"] == 50 - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.close_cover_tilt", - hass, - payload={"rangeValue": 0}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 0 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "SetRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValue": 100}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - - call, msg = await assert_request_calls_service( - "Alexa.RangeController", - "AdjustRangeValue", - "cover#test_tilt_range", - "cover.open_cover_tilt", - hass, - payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False}, - instance="cover.tilt", - ) - properties = msg["context"]["properties"][0] - assert properties["name"] == "rangeValue" - assert properties["namespace"] == "Alexa.RangeController" - assert properties["value"] == 100 - call, msg = await assert_request_calls_service( "Alexa.RangeController", "AdjustRangeValue", diff --git a/tests/components/aosmith/__init__.py b/tests/components/aosmith/__init__.py new file mode 100644 index 00000000000..89845dda42e --- /dev/null +++ b/tests/components/aosmith/__init__.py @@ -0,0 +1 @@ +"""Tests for the A. O. Smith integration.""" diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py new file mode 100644 index 00000000000..f0ece65d56f --- /dev/null +++ b/tests/components/aosmith/conftest.py @@ -0,0 +1,74 @@ +"""Common fixtures for the A. O. Smith tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from py_aosmith import AOSmithAPIClient +import pytest + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import MockConfigEntry, load_json_array_fixture + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "testemail@example.com", + CONF_PASSWORD: "test-password", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=FIXTURE_USER_INPUT, + unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aosmith.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def get_devices_fixture() -> str: + """Return the name of the fixture to use for get_devices.""" + return "get_devices" + + +@pytest.fixture +async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked client.""" + get_devices_fixture = load_json_array_fixture(f"{get_devices_fixture}.json", DOMAIN) + + client_mock = MagicMock(AOSmithAPIClient) + client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) + + return client_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + with patch( + "homeassistant.components.aosmith.AOSmithAPIClient", return_value=mock_client + ): + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/aosmith/fixtures/get_devices.json b/tests/components/aosmith/fixtures/get_devices.json new file mode 100644 index 00000000000..e34c50cd270 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_mode_pending.json b/tests/components/aosmith/fixtures/get_devices_mode_pending.json new file mode 100644 index 00000000000..a12f1d95f13 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_mode_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": true, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json new file mode 100644 index 00000000000..249024e1f1e --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json @@ -0,0 +1,42 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json new file mode 100644 index 00000000000..4d6e7613cf2 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": true, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr new file mode 100644 index 00000000000..fb80dc06917 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'basement', + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aosmith', + 'junctionId', + ), + }), + 'is_new': False, + 'manufacturer': 'A. O. Smith', + 'model': 'HPTS-50 200 202172000', + 'name': 'My water heater', + 'name_by_user': None, + 'serial_number': 'serial', + 'suggested_area': 'Basement', + 'sw_version': '2.14', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..2293a6c7b65 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': None, + 'friendly_name': 'My water heater', + 'max_temp': 130, + 'min_temp': 95, + 'operation_list': list([ + 'eco', + 'heat_pump', + 'electric', + ]), + 'operation_mode': 'heat_pump', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 130, + }), + 'context': , + 'entity_id': 'water_heater.my_water_heater', + 'last_changed': , + 'last_updated': , + 'state': 'heat_pump', + }) +# --- diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py new file mode 100644 index 00000000000..5d3e986e05e --- /dev/null +++ b/tests/components/aosmith/test_config_flow.py @@ -0,0 +1,174 @@ +"""Test the A. O. Smith config flow.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithInvalidCredentialsException +import pytest + +from homeassistant import config_entries +from homeassistant.components.aosmith.const import DOMAIN, REGULAR_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.aosmith.conftest import FIXTURE_USER_INPUT + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result2["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error_key"), + [ + (AOSmithInvalidCredentialsException("Invalid credentials"), "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error_key: str, +) -> None: + """Test handling an exception and then recovering on the second attempt.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_error_key} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result3["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_flow_retry( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works with retry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # First attempt at reauth - authentication fails again + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithInvalidCredentialsException("Authentication error"), + ): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Second attempt at reauth - authentication succeeds + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result3 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/aosmith/test_device.py b/tests/components/aosmith/test_device.py new file mode 100644 index 00000000000..596f380290e --- /dev/null +++ b/tests/components/aosmith/test_device.py @@ -0,0 +1,23 @@ +"""Tests for the device created by the A. O. Smith integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of the device.""" + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "junctionId")}, + ) + + assert reg_device == snapshot diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py new file mode 100644 index 00000000000..8ab699e6f1c --- /dev/null +++ b/tests/components/aosmith/test_init.py @@ -0,0 +1,71 @@ +"""Tests for the initialization of the A. O. Smith integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithUnknownException +import pytest + +from homeassistant.components.aosmith.const import ( + DOMAIN, + FAST_INTERVAL, + REGULAR_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: + """Test setup of the config entry.""" + mock_config_entry = init_integration + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry not ready.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithUnknownException("Unknown error"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("get_devices_fixture", "time_to_wait", "expected_call_count"), + [ + ("get_devices", REGULAR_INTERVAL, 1), + ("get_devices", FAST_INTERVAL, 0), + ("get_devices_mode_pending", FAST_INTERVAL, 1), + ("get_devices_setpoint_pending", FAST_INTERVAL, 1), + ], +) +async def test_update( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + time_to_wait: timedelta, + expected_call_count: int, +) -> None: + """Test data update with differing intervals depending on device status.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert mock_client.get_devices.call_count == 1 + + freezer.tick(time_to_wait) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_client.get_devices.call_count == 1 + expected_call_count diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py new file mode 100644 index 00000000000..61cb159c82a --- /dev/null +++ b/tests/components/aosmith/test_water_heater.py @@ -0,0 +1,147 @@ +"""Tests for the water heater platform of the A. O. Smith integration.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, +) +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, + ATTR_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the setup of the water heater entity.""" + entry = entity_registry.async_get("water_heater.my_water_heater") + assert entry + assert entry.unique_id == "junctionId" + + state = hass.states.get("water_heater.my_water_heater") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My water heater" + + +async def test_state( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the water heater entity.""" + state = hass.states.get("water_heater.my_water_heater") + assert state == snapshot + + +@pytest.mark.parametrize( + ("get_devices_fixture"), + ["get_devices_no_vacation_mode"], +) +async def test_state_away_mode_unsupported( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test that away mode is not supported if the water heater does not support vacation mode.""" + state = hass.states.get("water_heater.my_water_heater") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + +@pytest.mark.parametrize( + ("hass_mode", "aosmith_mode"), + [ + (STATE_HEAT_PUMP, AOSMITH_MODE_HEAT_PUMP), + (STATE_ECO, AOSMITH_MODE_HYBRID), + (STATE_ELECTRIC, AOSMITH_MODE_ELECTRIC), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_mode: str, + aosmith_mode: str, +) -> None: + """Test setting the operation mode.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_OPERATION_MODE: hass_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test setting the target temperature.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.my_water_heater", ATTR_TEMPERATURE: 120}, + ) + await hass.async_block_till_done() + + mock_client.update_setpoint.assert_called_once_with("junctionId", 120) + + +@pytest.mark.parametrize( + ("hass_away_mode", "aosmith_mode"), + [ + (True, AOSMITH_MODE_VACATION), + (False, AOSMITH_MODE_HYBRID), + ], +) +async def test_away_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_away_mode: bool, + aosmith_mode: str, +) -> None: + """Test turning away mode on/off.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_AWAY_MODE: hass_away_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b0eee051331..4c4e0af8705 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -95,10 +95,7 @@ async def async_init_integration( entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", return_value=status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=status): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 48d57890320..6a69d4e974e 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -24,7 +24,7 @@ def _patch_setup(): async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: """Test config flow setup with connection error.""" - with patch("apcaccess.status.get") as mock_get: + with patch("aioapcaccess.request_status") as mock_get: mock_get.side_effect = OSError() result = await hass.config_entries.flow.async_init( @@ -38,10 +38,7 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: async def test_config_flow_no_status(hass: HomeAssistant) -> None: """Test config flow setup with successful connection but no status is reported.""" - with ( - patch("apcaccess.status.parse", return_value={}), # Returns no status. - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value={}): # Returns no status. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -64,11 +61,10 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup(), ): - mock_parse.return_value = MOCK_STATUS + mock_request_status.return_value = MOCK_STATUS # Now, create the integration again using the same config data, we should reject # the creation due same host / port. @@ -98,7 +94,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: # Now we change the serial number and add it again. This should be successful. another_device_status = copy(MOCK_STATUS) another_device_status["SERIALNO"] = MOCK_STATUS["SERIALNO"] + "ZZZ" - mock_parse.return_value = another_device_status + mock_request_status.return_value = another_device_status result = await hass.config_entries.flow.async_init( DOMAIN, @@ -112,8 +108,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: async def test_flow_works(hass: HomeAssistant) -> None: """Test successful creation of config entries via user configuration.""" with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS), - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status", return_value=MOCK_STATUS), _patch_setup() as mock_setup, ): result = await hass.config_entries.flow.async_init( @@ -152,12 +147,11 @@ async def test_flow_minimal_status( integration will vary. """ with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup() as mock_setup, ): status = MOCK_MINIMAL_STATUS | extra_status - mock_parse.return_value = status + mock_request_status.return_value = status result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 756fa07f120..c65efe25bb9 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -1,4 +1,5 @@ """Test init of APCUPSd integration.""" +import asyncio from collections import OrderedDict from unittest.mock import patch @@ -97,7 +98,11 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: assert state1.state != state2.state -async def test_connection_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "error", + (OSError(), asyncio.IncompleteReadError(partial=b"", expected=0)), +) +async def test_connection_error(hass: HomeAssistant, error: Exception) -> None: """Test connection error during integration setup.""" entry = MockConfigEntry( version=1, @@ -109,10 +114,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", side_effect=OSError()), - patch("apcaccess.status.get"), - ): + with patch("aioapcaccess.request_status", side_effect=error): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -156,12 +158,9 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state != STATE_UNAVAILABLE assert pytest.approx(float(state.state)) == 14.0 - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status") as mock_request_status: # Mock a network error and then trigger an auto-polling event. - mock_parse.side_effect = OSError() + mock_request_status.side_effect = OSError() future = utcnow() + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -172,8 +171,8 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE # Reset the API to return a new status and update. - mock_parse.side_effect = None - mock_parse.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + mock_request_status.side_effect = None + mock_request_status.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} future = future + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index bff1b858216..24aae1d3937 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -127,10 +127,7 @@ async def test_state_update(hass: HomeAssistant) -> None: assert state.state == "14.0" new_status = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} - with ( - patch("apcaccess.status.parse", return_value=new_status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=new_status): future = utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -154,11 +151,8 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): - mock_parse.return_value = MOCK_STATUS | { + with patch("aioapcaccess.request_status") as mock_request_status: + mock_request_status.return_value = MOCK_STATUS | { "LOADPCT": "15.0 Percent", "BCHARGE": "99.0 Percent", } @@ -174,8 +168,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: ) # Even if we requested updates for two entities, our integration should smartly # group the API calls to just one. - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 # The new state should be effective. state = hass.states.get("sensor.ups_load") @@ -194,10 +187,9 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS) as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): + with patch( + "aioapcaccess.request_status", return_value=MOCK_STATUS + ) as mock_request_status: # Fast-forward time to just pass the initial debouncer cooldown. future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) async_fire_time_changed(hass, future) @@ -207,5 +199,4 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_input_voltage"]}, blocking=True, ) - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index f97b55c3ede..08cb77b4559 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -1,9 +1,10 @@ """The tests for the Home Assistant API component.""" +import asyncio from http import HTTPStatus import json from unittest.mock import patch -from aiohttp import web +from aiohttp import ServerDisconnectedError, web from aiohttp.test_utils import TestClient import pytest import voluptuous as vol @@ -352,26 +353,41 @@ async def test_api_call_service_with_data( assert state["attributes"] == {"data": 1} -async def test_api_call_service_timeout( +async def test_api_call_service_client_closed( hass: HomeAssistant, mock_api_client: TestClient ) -> None: - """Test if the API does not fail on long running services.""" + """Test that services keep running if client is closed.""" test_value = [] fut = hass.loop.create_future() + service_call_started = asyncio.Event() async def listener(service_call): """Wait and return after mock_api_client.post finishes.""" + service_call_started.set() value = await fut test_value.append(value) hass.services.async_register("test_domain", "test_service", listener) - with patch("homeassistant.components.api.SERVICE_WAIT_TIMEOUT", 0): - await mock_api_client.post("/api/services/test_domain/test_service") - assert len(test_value) == 0 - fut.set_result(1) - await hass.async_block_till_done() + api_task = hass.async_create_task( + mock_api_client.post("/api/services/test_domain/test_service") + ) + + await service_call_started.wait() + + assert len(test_value) == 0 + + await mock_api_client.close() + + assert len(test_value) == 0 + assert api_task.done() + + with pytest.raises(ServerDisconnectedError): + await api_task + + fut.set_result(1) + await hass.async_block_till_done() assert len(test_value) == 1 assert test_value[0] == 1 diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index cc56894cf0d..807eff4ef8d 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -479,7 +479,7 @@ async def test_config_flow( resp = await client.cmd("delete", {"application_credentials_id": ID}) assert not resp.get("success") assert "error" in resp - assert resp["error"].get("code") == "unknown_error" + assert resp["error"].get("code") == "home_assistant_error" assert ( resp["error"].get("message") == "Cannot delete credential in use by integration fake_integration" diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 1f625528806..072b1ff730a 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -650,6 +650,33 @@ 'message': 'Timeout running pipeline', }) # --- +# name: test_pipeline_empty_tts_output + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': None, + 'timeout': 300, + }), + }) +# --- +# name: test_pipeline_empty_tts_output.1 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': '', + 'voice': 'james_earl_jones', + }) +# --- +# name: test_pipeline_empty_tts_output.2 + dict({ + 'tts_output': dict({ + }), + }) +# --- +# name: test_pipeline_empty_tts_output.3 + None +# --- # name: test_stt_provider_missing dict({ 'language': 'en', diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 24a4a92536d..882d3a80fb3 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,4 +1,5 @@ """Test Voice Assistant init.""" +import asyncio from dataclasses import asdict import itertools as it from pathlib import Path @@ -569,6 +570,69 @@ async def test_pipeline_saved_audio_write_error( ) +async def test_pipeline_saved_audio_empty_queue( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that saved audio thread closes WAV file even if there's an empty queue.""" + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, + ) + + def event_callback(event: assist_pipeline.PipelineEvent): + if event.type == "run-end": + # Verify WAV file exists, but contains no data + pipeline_dirs = list(temp_dir.iterdir()) + run_dirs = list(pipeline_dirs[0].iterdir()) + wav_path = next(run_dirs[0].iterdir()) + with wave.open(str(wav_path), "rb") as wav_file: + assert wav_file.getnframes() == 0 + + async def audio_data(): + # Force timeout in _pipeline_debug_recording_thread_proc + await asyncio.sleep(1) + yield b"not used" + + # Wrap original function to time out immediately + _pipeline_debug_recording_thread_proc = ( + assist_pipeline.pipeline._pipeline_debug_recording_thread_proc + ) + + def proc_wrapper(run_recording_dir, queue): + _pipeline_debug_recording_thread_proc( + run_recording_dir, queue, message_timeout=0 + ) + + with patch( + "homeassistant.components.assist_pipeline.pipeline._pipeline_debug_recording_thread_proc", + proc_wrapper, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=event_callback, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.STT, + ) + + async def test_wake_word_detection_aborted( hass: HomeAssistant, mock_stt_provider: MockSttProvider, diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 931b31dd77b..0e2a3ad538c 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2452,3 +2452,54 @@ async def test_device_capture_queue_full( assert msg["event"] == snapshot assert msg["event"]["type"] == "end" assert msg["event"]["overflow"] + + +async def test_pipeline_empty_tts_output( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with a empty text-to-speech text.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": { + "text": "", + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + assert not msg["event"]["data"]["tts_output"] + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 0f29c84c820..72cbc37d571 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -14,9 +14,9 @@ from .common import ASUSWRT_BASE, MOCK_MACS, ROUTER_MAC_ADDR, new_device ASUSWRT_HTTP_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtHttp" ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" -MOCK_BYTES_TOTAL = [60000000000, 50000000000] +MOCK_BYTES_TOTAL = 60000000000, 50000000000 MOCK_BYTES_TOTAL_HTTP = dict(enumerate(MOCK_BYTES_TOTAL)) -MOCK_CURRENT_TRANSFER_RATES = [20000000, 10000000] +MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 61521c49b79..a78682ced6d 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -62,6 +62,8 @@ async def test_sensors(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns, + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -102,6 +104,8 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) # sun is up with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, @@ -133,7 +137,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraTimeoutError("No response after 3 tries"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -145,7 +149,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 4) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -159,7 +163,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraError("No response after 10 seconds"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 6) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -174,6 +178,8 @@ async def test_sensor_unknown_error(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraError("another error"), + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch("serial.Serial.isOpen", return_value=True): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index a33ca702bcf..4088b1819fa 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError @@ -167,28 +168,25 @@ async def test_auth_code_checks_local_only_user( assert error["error"] == "access_denied" -def test_auth_code_store_expiration(mock_credential) -> None: +def test_auth_code_store_expiration( + mock_credential, freezer: FrozenDateTimeFactory +) -> None: """Test that the auth code store will not return expired tokens.""" store, retrieve = auth._create_auth_code_store() client_id = "bla" now = utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=10) - ): - assert retrieve(client_id, code) is None + freezer.move_to(now + timedelta(minutes=10)) + assert retrieve(client_id, code) is None - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", - return_value=now + timedelta(minutes=9, seconds=59), - ): - assert retrieve(client_id, code) == mock_credential + freezer.move_to(now + timedelta(minutes=9, seconds=59)) + assert retrieve(client_id, code) == mock_credential def test_auth_code_store_requires_credentials(mock_credential) -> None: diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6d83b00517d..359303c51fd 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1102,7 +1102,7 @@ async def test_reload_automation_when_blueprint_changes( autospec=True, return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml", + "homeassistant.components.blueprint.models.yaml.load_yaml_dict", autospec=True, return_value=blueprint_config, ): diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 438b47f38c5..ccc326dac1f 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_PIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ async def test_refresh_service_calls( assert mock_blink_api.refresh.call_count == 2 - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_REFRESH, @@ -66,8 +66,6 @@ async def test_refresh_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_video_service_calls( hass: HomeAssistant, @@ -90,18 +88,17 @@ async def test_video_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) caplog.clear() @@ -118,7 +115,7 @@ async def test_video_service_calls( ) mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_VIDEO, @@ -130,22 +127,19 @@ async def test_video_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - assert "Can't write image" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_VIDEO, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILENAME: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=False) @@ -171,18 +165,17 @@ async def test_picture_service_calls( assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "no access to path!" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} @@ -202,21 +195,20 @@ async def test_picture_service_calls( mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( side_effect=OSError ) - caplog.clear() - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - assert "Can't write recent clips to directory" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + { + ATTR_DEVICE_ID: [device_entry.id], + CONF_NAME: CAMERA_NAME, + CONF_FILE_PATH: FILENAME, + }, + blocking=True, + ) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SAVE_RECENT_CLIPS, @@ -228,8 +220,6 @@ async def test_picture_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - async def test_pin_service_calls( hass: HomeAssistant, @@ -259,7 +249,7 @@ async def test_pin_service_calls( ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, @@ -267,8 +257,6 @@ async def test_pin_service_calls( blocking=True, ) - assert "Device 'bad-device_id' not found in device registry" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -325,7 +313,7 @@ async def test_service_called_with_non_blink_device( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, service, @@ -333,8 +321,6 @@ async def test_service_called_with_non_blink_device( blocking=True, ) - assert f"Device '{device_entry.id}' is not a blink device" in str(execinfo) - @pytest.mark.parametrize( ("service", "params"), @@ -382,12 +368,10 @@ async def test_service_called_with_unloaded_entry( parameters = {ATTR_DEVICE_ID: [device_entry.id]} parameters.update(params) - with pytest.raises(HomeAssistantError) as execinfo: + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, service, parameters, blocking=True, ) - - assert "Mock Title is not loaded" in str(execinfo) diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 213dff89597..b0439896c25 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -438,7 +438,7 @@ async def test_delete_blueprint_in_use_by_automation( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } @@ -484,6 +484,6 @@ async def test_delete_blueprint_in_use_by_script( assert msg["id"] == 9 assert not msg["success"] assert msg["error"] == { - "code": "unknown_error", + "code": "home_assistant_error", "message": "Blueprint in use", } diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 55d995dd63c..5261e7371f3 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -5,11 +5,12 @@ from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import BaseHaScanner, BluetoothManager from homeassistant.components.bluetooth import ( DOMAIN, @@ -19,8 +20,6 @@ from homeassistant.components.bluetooth import ( async_get_advertisement_callback, models, ) -from homeassistant.components.bluetooth.base_scanner import BaseHaScanner -from homeassistant.components.bluetooth.manager import BluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,6 +36,7 @@ __all__ = ( "generate_advertisement_data", "generate_ble_device", "MockBleakClient", + "patch_bluetooth_time", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -56,6 +56,22 @@ BLE_DEVICE_DEFAULTS = { } +@contextmanager +def patch_bluetooth_time(mock_time: float) -> None: + """Patch the bluetooth time.""" + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=mock_time, + ), patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", return_value=mock_time + ), patch( + "habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time + ), patch( + "habluetooth.manager.monotonic_time_coarse", return_value=mock_time + ), patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time): + yield + + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 5f166a3fca2..4ec6c4e5388 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -50,7 +50,7 @@ def macos_adapter(): "homeassistant.components.bluetooth.platform.system", return_value="Darwin", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", @@ -76,7 +76,7 @@ def no_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -97,7 +97,7 @@ def one_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -128,7 +128,7 @@ def two_adapters_fixture(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" @@ -168,7 +168,7 @@ def one_adapter_old_bluez(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index f04ea2873f0..8681287baa2 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -3,6 +3,7 @@ from datetime import timedelta import time from unittest.mock import patch +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest from homeassistant.components.bluetooth import ( @@ -10,9 +11,6 @@ from homeassistant.components.bluetooth import ( async_register_scanner, async_track_unavailable, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.components.bluetooth.const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, @@ -352,7 +350,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) switchbot_device_went_unavailable = False - scanner = FakeScanner(hass, "new", "fake_adapter") + scanner = FakeScanner("new", "fake_adapter") cancel_scanner = async_register_scanner(hass, scanner, False) @callback diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 63b60c8f487..30e9554f2af 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -7,9 +7,9 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_scanner_by_source, async_scanner_devices_by_address, ) @@ -27,7 +27,7 @@ from . import ( async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test we can get a scanner by source.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) assert async_scanner_by_source(hass, "hci2") is hci2_scanner @@ -46,7 +46,7 @@ async def test_async_scanner_devices_by_address_connectable( """Test getting scanner devices by address with connectable devices.""" manager = _get_manager() - class FakeInjectableScanner(BaseHaRemoteScanner): + class FakeInjectableScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -135,7 +135,7 @@ async def test_async_scanner_devices_by_address_non_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeStaticScanner(hass, "esp32", "esp32", connector) + scanner = FakeStaticScanner("esp32", "esp32", connector) cancel = manager.async_register_scanner(scanner, False) assert scanner.discovered_devices_and_advertisement_data == { diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 31d90a6e93d..2e2be0e7963 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -8,18 +8,16 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, storage, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, -) from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -37,11 +35,35 @@ from . import ( _get_manager, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture +class FakeScanner(HomeAssistantRemoteScanner): + """Fake scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) + + @pytest.mark.parametrize("name_2", [None, "w"]) async def test_remote_scanner( hass: HomeAssistant, enable_bluetooth: None, name_2: str | None @@ -89,23 +111,6 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -173,23 +178,6 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -214,10 +202,7 @@ async def test_remote_scanner_expires_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -248,23 +233,6 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -297,10 +265,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -313,10 +278,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -346,23 +308,6 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -418,7 +363,7 @@ async def test_restore_history_remote_adapter( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -434,7 +379,7 @@ async def test_restore_history_remote_adapter( cancel() unsetup() - scanner = BaseHaRemoteScanner( + scanner = HomeAssistantRemoteScanner( hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", @@ -470,23 +415,6 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -514,11 +442,8 @@ async def test_device_with_ten_minute_advertising_interval( connectable=False, ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv, new_time) original_device = scanner.discovered_devices_and_advertisement_data[ bparasite_device.address @@ -527,11 +452,10 @@ async def test_device_with_ten_minute_advertising_interval( for _ in range(1, 20): new_time += advertising_interval - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, new_time + ) # Make sure the BLEDevice object gets updated # and not replaced @@ -545,10 +469,7 @@ async def test_device_with_ten_minute_advertising_interval( bluetooth.async_address_present(hass, bparasite_device.address, False) is True ) assert bparasite_device_went_unavailable is False - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=new_time, - ): + with patch_bluetooth_time(new_time): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=future_time)) await hass.async_block_till_done() @@ -558,13 +479,7 @@ async def test_device_with_ten_minute_advertising_interval( future_time + advertising_interval + TRACKER_BUFFERING_WOBBLE_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ): + with patch_bluetooth_time(missed_advertisement_future_time): # Fire once for the scanner to expire the device async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -592,25 +507,6 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(BaseHaRemoteScanner): - """A fake remote scanner.""" - - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), @@ -628,10 +524,7 @@ async def test_scanner_stops_responding( + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): + with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -652,11 +545,10 @@ async def test_scanner_stops_responding( failure_reached_time += 1 - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(failure_reached_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, failure_reached_time + ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 0e8b2b54f06..a69c26a16ea 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,12 +3,13 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.core import HomeAssistant @@ -25,6 +26,21 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +class FakeHaScanner(HaScanner): + """Fake HaScanner.""" + + @property + def discovered_devices_and_advertisement_data(self): + """Return the discovered devices and advertisement data.""" + return { + "44:44:33:11:23:45": ( + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_advertisement_data(local_name="x"), + ) + } + + +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -38,15 +54,8 @@ async def test_diagnostics( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. + with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - generate_advertisement_data(local_name="x"), - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Linux", ), patch( @@ -88,25 +97,25 @@ async def test_diagnostics( "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 2, }, }, "dbus": { @@ -126,63 +135,42 @@ async def test_diagnostics( } }, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5, "hci1": 2}, - "allocations_by_adapter": {"hci0": [], "hci1": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 2, }, }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {}, "timings": {}, }, - "connectable_history": [], "all_history": [], + "connectable_history": [], "scanners": [ { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], + "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -216,7 +204,7 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "adapter": "hci1", @@ -243,13 +231,19 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:02", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5, "hci1": 2}, + "allocations_by_adapter": {"hci0": [], "hci1": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_macos( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -269,14 +263,6 @@ async def test_diagnostics_macos( ) with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - switchbot_adv, - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Darwin", ), patch( @@ -297,70 +283,36 @@ async def test_diagnostics_macos( inject_advertisement(hass, switchbot_device, switchbot_adv) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "manager": { - "slot_manager": { - "adapter_slots": {"Core Bluetooth": 5}, - "allocations_by_adapter": {"Core Bluetooth": []}, - "manager": False, - }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, - "connectable_history": [ - { - "address": "44:44:33:11:23:45", - "advertisement": [ - "wohand", - {"1": {"__type": "", "repr": "b'\\x01'"}}, - {}, - [], - -127, - -127, - [[]], - ], - "device": { - "__type": "", - "repr": "BLEDevice(44:44:33:11:23:45, wohand)", - }, - "connectable": True, - "manufacturer_data": { - "1": {"__type": "", "repr": "b'\\x01'"} - }, - "name": "wohand", - "rssi": -127, - "service_data": {}, - "service_uuids": [], - "source": "local", - "time": ANY, - } - ], "all_history": [ { "address": "44:44:33:11:23:45", @@ -373,11 +325,39 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, + "name": "wohand", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "local", + "time": ANY, + } + ], + "connectable_history": [ + { + "address": "44:44:33:11:23:45", + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, + "device": { + "__type": "", + "repr": "BLEDevice(44:44:33:11:23:45, wohand)", + }, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -396,13 +376,8 @@ async def test_diagnostics_macos( { "address": "44:44:33:11:23:45", "advertisement_data": [ - "wohand", - { - "1": { - "__type": "", - "repr": "b'\\x01'", - } - }, + "x", + {}, {}, [], -127, @@ -420,13 +395,19 @@ async def test_diagnostics_macos( "scanning": True, "source": "Core Bluetooth", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", } ], + "slot_manager": { + "adapter_slots": {"Core Bluetooth": 5}, + "allocations_by_adapter": {"Core Bluetooth": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_remote_adapter( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -442,7 +423,7 @@ async def test_diagnostics_remote_adapter( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -497,17 +478,12 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, "dbus": {}, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5}, - "allocations_by_adapter": {"hci0": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -521,8 +497,8 @@ async def test_diagnostics_remote_adapter( } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -596,19 +572,34 @@ async def test_diagnostics_remote_adapter( }, { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [], + "discovered_devices_and_advertisement_data": [ + { + "address": "44:44:33:11:23:45", + "advertisement_data": [ + "x", + {}, + {}, + [], + -127, + -127, + [[]], + ], + "details": None, + "name": "x", + "rssi": -127, + } + ], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "connectable": False, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, - "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -639,11 +630,17 @@ async def test_diagnostics_remote_adapter( "name": "esp32", "scanning": True, "source": "esp32", - "storage": None, - "type": "FakeScanner", "start_time": ANY, + "storage": None, + "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "type": "FakeScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5}, + "allocations_by_adapter": {"hci0": []}, + "manager": False, + }, }, } diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 21fade843f5..63ff735ca43 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import scanner import pytest from homeassistant.components import bluetooth @@ -17,7 +18,6 @@ from homeassistant.components.bluetooth import ( async_process_advertisements, async_rediscover_address, async_track_unavailable, - scanner, ) from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, @@ -107,7 +107,7 @@ async def test_setup_and_stop_passive( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, ): assert await async_setup_component( @@ -158,7 +158,7 @@ async def test_setup_and_stop_old_bluez( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( @@ -185,7 +185,7 @@ async def test_setup_and_stop_no_bluetooth( {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -206,7 +206,7 @@ async def test_setup_and_stop_broken_bluetooth( """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,7 +231,7 @@ async def test_setup_and_stop_broken_bluetooth_hanging( await asyncio.sleep(1) with patch.object(scanner, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -251,7 +251,7 @@ async def test_setup_and_retry_adapter_not_yet_available( """Test we retry if the adapter is not yet available.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -267,14 +267,14 @@ async def test_setup_and_retry_adapter_not_yet_available( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -286,7 +286,7 @@ async def test_no_race_during_manual_reload_in_retry_state( """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -302,7 +302,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -310,7 +310,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -322,7 +322,7 @@ async def test_calling_async_discovered_devices_no_bluetooth( """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -2815,7 +2815,7 @@ async def test_scanner_count_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test getting the connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() @@ -2823,7 +2823,7 @@ async def test_scanner_count_connectable( async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test getting the connectable and non-connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") + scanner = FakeScanner("any", "any") cancel = bluetooth.async_register_scanner(hass, scanner, False) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 6c89074e471..361f0cd008f 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -12,12 +12,12 @@ import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothChange, BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_ble_device_from_address, async_get_advertisement_callback, async_get_fallback_availability_interval, @@ -56,7 +56,7 @@ from tests.common import async_fire_time_changed, load_fixture @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner(hass, "hci0", "hci0") + hci0_scanner = FakeScanner("hci0", "hci0") cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True) yield cancel() @@ -65,7 +65,7 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner(hass, "hci1", "hci1") + hci1_scanner = FakeScanner("hci1", "hci1") cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True) yield cancel() @@ -562,7 +562,7 @@ async def test_switching_adapters_when_one_goes_away( ) -> None: """Test switching adapters when one goes away.""" cancel_hci2 = bluetooth.async_register_scanner( - hass, FakeScanner(hass, "hci2", "hci2"), True + hass, FakeScanner("hci2", "hci2"), True ) address = "44:44:33:11:23:45" @@ -612,7 +612,7 @@ async def test_switching_adapters_when_one_stop_scanning( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when stops scanning.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") + hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) address = "44:44:33:11:23:45" @@ -704,7 +704,7 @@ async def test_goes_unavailable_connectable_only_and_recovers( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: @@ -877,7 +877,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 746f52537cb..8cffbe685b6 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -10,9 +10,9 @@ from bleak.backends.scanner import AdvertisementData import pytest from homeassistant.components.bluetooth import ( - BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, + HomeAssistantRemoteScanner, ) from homeassistant.components.bluetooth.wrappers import ( HaBleakClientWrapper, @@ -107,7 +107,6 @@ async def test_wrapped_bleak_client_local_adapter_only( return None scanner = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", ) @@ -158,7 +157,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100 ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -271,7 +270,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -336,7 +335,7 @@ async def test_ble_device_with_proxy_clear_cache( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -439,7 +438,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" @@ -553,7 +552,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "esp32_no_connection_slot", ) - class FakeScanner(BaseHaRemoteScanner): + class FakeScanner(HomeAssistantRemoteScanner): @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index bc32a5b302d..7673acb80dc 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -14,7 +14,6 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) -from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -26,10 +25,19 @@ from . import ( async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import MockConfigEntry, async_fire_time_changed +# If the adapter is in a stuck state the following errors are raised: +NEED_RESET_ERRORS = [ + "org.bluez.Error.Failed", + "org.bluez.Error.InProgress", + "org.bluez.Error.NotReady", + "not found", +] + async def test_config_entry_can_be_reloaded_when_stop_raises( hass: HomeAssistant, @@ -42,7 +50,7 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) @@ -57,10 +65,8 @@ async def test_dbus_socket_missing_in_container( ) -> None: """Test we handle dbus being missing in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -79,10 +85,8 @@ async def test_dbus_socket_missing( ) -> None: """Test we handle dbus being missing.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -101,10 +105,8 @@ async def test_dbus_broken_pipe_in_container( ) -> None: """Test we handle dbus broken pipe in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -124,10 +126,8 @@ async def test_dbus_broken_pipe( ) -> None: """Test we handle dbus broken pipe.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -148,7 +148,7 @@ async def test_invalid_dbus_message( """Test we handle invalid dbus message.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ): await async_setup_with_one_adapter(hass) @@ -168,10 +168,10 @@ async def test_adapter_needs_reset_at_start( """Test we cycle the adapter when it needs a restart.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=[BleakError(error), None], ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -216,7 +216,7 @@ async def test_recovery_from_dbus_restart( return mock_discovered with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): await async_setup_with_one_adapter(hass) @@ -227,9 +227,8 @@ async def test_recovery_from_dbus_restart( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -237,9 +236,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # Fire a callback to reset the timer - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ): _callback( generate_ble_device("44:44:33:11:23:42", "any_name"), @@ -247,9 +245,8 @@ async def test_recovery_from_dbus_restart( ) # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -257,9 +254,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # We hit the timer, so we restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) @@ -302,11 +298,10 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -317,9 +312,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -327,9 +321,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -337,13 +330,12 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -391,11 +383,10 @@ async def test_adapter_scanner_fails_to_start_first_time( scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -406,9 +397,8 @@ async def test_adapter_scanner_fails_to_start_first_time( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -416,9 +406,8 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -426,13 +415,12 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -442,13 +430,12 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer again the previous start call failed, make sure # we try again - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -503,16 +490,15 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -554,26 +540,22 @@ async def test_restart_takes_longer_than_watchdog_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, - ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True - ): + ), patch("habluetooth.util.recover_adapter", return_value=True): await async_setup_with_one_adapter(hass) assert called_start == 1 # Now force a recover adapter 2x for _ in range(2): - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ): @@ -617,7 +599,7 @@ async def test_setup_and_stop_macos( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index f69f8971479..d3c2e1b54db 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -12,9 +12,9 @@ import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, - BaseHaRemoteScanner, BluetoothServiceInfoBleak, HaBluetoothConnector, + HomeAssistantRemoteScanner, async_get_advertisement_callback, ) from homeassistant.components.bluetooth.usage import ( @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from . import _get_manager, generate_advertisement_data, generate_ble_device -class FakeScanner(BaseHaRemoteScanner): +class FakeScanner(HomeAssistantRemoteScanner): """Fake scanner.""" def __init__( diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 32405d93e6b..b3af5bc59b6 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -413,7 +413,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1288,7 +1287,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -1979,7 +1977,6 @@ 'charging_settings': dict({ }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isAssociated': False, 'isLmmEnabled': False, @@ -2734,7 +2731,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', @@ -5070,7 +5066,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'is_metric': True, 'mappingInfo': dict({ 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index db1c0fc787d..e202433c8d6 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -26,6 +26,7 @@ from homeassistant.components.fan import ( SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -251,10 +252,14 @@ async def test_turn_on_fan_preset_mode_not_supported(hass: HomeAssistant) -> Non props={"max_speed": 6}, ) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE) - with patch_bond_action(), patch_bond_device_state(), pytest.raises(ValueError): + with patch_bond_action(), patch_bond_device_state(), pytest.raises( + NotValidPresetModeError + ): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index 55ae0d564d0..a90529297be 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -1,4 +1,5 @@ """The tests for the webdav todo component.""" +from datetime import UTC, date, datetime from typing import Any from unittest.mock import MagicMock, Mock @@ -17,7 +18,7 @@ from tests.typing import WebSocketGenerator CALENDAR_NAME = "My Tasks" ENTITY_NAME = "My tasks" TEST_ENTITY = "todo.my_tasks" -SUPPORTED_FEATURES = 7 +SUPPORTED_FEATURES = 119 TODO_NO_STATUS = """BEGIN:VCALENDAR VERSION:2.0 @@ -40,6 +41,12 @@ STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" +RESULT_ITEM = { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", +} + TODO_COMPLETED = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//E-Corp.//CalDAV Client//EN @@ -69,6 +76,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_tz(hass: HomeAssistant) -> None: + """Fixture to set timezone with fixed offset year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.fixture(name="todos") def mock_todos() -> list[str]: """Fixture to return VTODO objects for the calendar.""" @@ -178,10 +191,53 @@ async def test_supported_components( assert (state is not None) == has_entity +@pytest.mark.parametrize( + ("item_data", "expcted_save_args", "expected_item"), + [ + ( + {}, + {"status": "NEEDS-ACTION", "summary": "Cheese"}, + RESULT_ITEM, + ), + ( + {"due_date": "2023-11-18"}, + {"status": "NEEDS-ACTION", "summary": "Cheese", "due": date(2023, 11, 18)}, + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "due": datetime(2023, 11, 18, 14, 30, 00, tzinfo=UTC), + }, + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + { + "status": "NEEDS-ACTION", + "summary": "Cheese", + "description": "Make sure to get Swiss", + }, + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "summary", + "due_date", + "due_datetime", + "description", + ], +) async def test_add_item( hass: HomeAssistant, config_entry: MockConfigEntry, + dav_client: Mock, calendar: Mock, + item_data: dict[str, Any], + expcted_save_args: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test adding an item to the list.""" calendar.search.return_value = [] @@ -197,16 +253,13 @@ async def test_add_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Cheese"}, + {"item": "Cheese", **item_data}, target={"entity_id": TEST_ENTITY}, blocking=True, ) assert calendar.save_todo.call_args - assert calendar.save_todo.call_args.kwargs == { - "status": "NEEDS-ACTION", - "summary": "Cheese", - } + assert calendar.save_todo.call_args.kwargs == expcted_save_args # Verify state was updated state = hass.states.get(TEST_ENTITY) @@ -235,20 +288,59 @@ async def test_add_item_failure( @pytest.mark.parametrize( - ("update_data", "expected_ics", "expected_state"), + ("update_data", "expected_ics", "expected_state", "expected_item"), [ ( {"rename": "Swiss Cheese"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, + ), + ( + {"status": "needs_action"}, + ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], + "1", + RESULT_ITEM, + ), + ( + {"status": "completed"}, + ["SUMMARY:Cheese", "STATUS:COMPLETED"], + "0", + {**RESULT_ITEM, "status": "completed"}, ), - ({"status": "needs_action"}, ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], "1"), - ({"status": "completed"}, ["SUMMARY:Cheese", "STATUS:COMPLETED"], "0"), ( {"rename": "Swiss Cheese", "status": "needs_action"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", + {**RESULT_ITEM, "summary": "Swiss Cheese"}, ), + ( + {"due_date": "2023-11-18"}, + ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], + "1", + {**RESULT_ITEM, "due": "2023-11-18"}, + ), + ( + {"due_datetime": "2023-11-18T08:30:00-06:00"}, + ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], + "1", + {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + ), + ( + {"description": "Make sure to get Swiss"}, + ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], + "1", + {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + ), + ], + ids=[ + "rename", + "status_needs_action", + "status_completed", + "rename_status", + "due_date", + "due_datetime", + "description", ], ) async def test_update_item( @@ -259,8 +351,9 @@ async def test_update_item( update_data: dict[str, Any], expected_ics: list[str], expected_state: str, + expected_item: dict[str, Any], ) -> None: - """Test creating a an item on the list.""" + """Test updating an item on the list.""" item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") calendar.search = MagicMock(return_value=[item]) @@ -295,6 +388,16 @@ async def test_update_item( assert state assert state.state == expected_state + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + return_response=True, + ) + assert result == {TEST_ENTITY: {"items": [expected_item]}} + async def test_update_item_failure( hass: HomeAssistant, @@ -506,7 +609,7 @@ async def test_subscribe( calendar: Mock, hass_ws_client: WebSocketGenerator, ) -> None: - """Test creating a an item on the list.""" + """Test subscription to item updates.""" item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") calendar.search = MagicMock(return_value=[item]) diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 6c1d593560e..00f8a34fb0c 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -73,8 +75,8 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert entry.unique_id == f"{HOST}:{PORT}" -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading a config entry.""" assert hass.state is CoreState.running diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 48421f5c41f..18a70fa9ab6 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -4,6 +4,8 @@ import socket import ssl from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import CoreState, HomeAssistant @@ -15,8 +17,8 @@ from .helpers import future_timestamp, static_datetime from tests.common import MockConfigEntry, async_fire_time_changed -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test async_setup_entry.""" assert hass.state is CoreState.running @@ -82,7 +84,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -98,7 +100,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert state.attributes.get("is_valid") next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -126,7 +128,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -143,7 +145,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=socket.gaierror, ): @@ -155,7 +157,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -171,7 +173,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=ssl.SSLError("something bad"), ): @@ -186,7 +188,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 87bb9cc9409..4a784a6eff1 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -242,7 +242,7 @@ async def test_remove_config_entry_from_device( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" # Make async_remove_config_entry_device return True can_remove = True @@ -365,7 +365,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device @@ -380,7 +380,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert ( response["error"]["message"] == "Config entry does not support device removal" ) @@ -397,7 +397,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to @@ -428,7 +428,7 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" # Try removing a config entry which can't be loaded from a device - allowed @@ -443,5 +443,5 @@ async def test_remove_config_entry_from_device_fails( response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Integration not found" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index c985565b1be..adf79a2ef96 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: yield -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( @@ -91,3 +91,12 @@ def tts_mutagen_mock_fixture(): from tests.components.tts.common import tts_mutagen_mock_fixture_helper yield from tts_mutagen_mock_fixture_helper() + + +@pytest.fixture(scope="session", autouse=True) +def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: + """Prevent ffmpeg from creating a subprocess.""" + with patch( + "homeassistant.components.ffmpeg.FFVersion.get_version", return_value="6.0" + ): + yield diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index fe94e2d5425..c68ec301280 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -307,8 +307,8 @@ async def test_device_area_context( turn_on_calls = async_mock_service(hass, "light", "turn_on") turn_off_calls = async_mock_service(hass, "light", "turn_off") - area_kitchen = area_registry.async_get_or_create("kitchen") - area_bedroom = area_registry.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("Kitchen") + area_bedroom = area_registry.async_get_or_create("Bedroom") # Create 2 lights in each area area_lights = defaultdict(list) @@ -323,7 +323,7 @@ async def test_device_area_context( "off", attributes={ATTR_FRIENDLY_NAME: f"{area.name} light {i}"}, ) - area_lights[area.name].append(light_entity) + area_lights[area.id].append(light_entity) # Create voice satellites in each area entry = MockConfigEntry() @@ -354,6 +354,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_kitchen.id # Verify only kitchen lights were targeted assert {s.entity_id for s in result.response.matched_states} == { @@ -375,6 +377,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { @@ -396,6 +400,8 @@ async def test_device_area_context( ) await hass.async_block_till_done() assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.intent is not None + assert result.response.intent.slots["area"]["value"] == area_bedroom.id # Verify only bedroom lights were targeted assert {s.entity_id for s in result.response.matched_states} == { diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 357371e4853..d38c65526c2 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -186,7 +186,6 @@ async def test_no_lights_or_groups( "state": STATE_ON, "attributes": { ATTR_EFFECT_LIST: [ - EFFECT_COLORLOOP, "carnival", "collide", "fading", diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 58a8c99ea3c..a3f607aee76 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -182,7 +182,7 @@ async def test_turn_on_with_preset_mode_only( assert state.state == STATE_OFF assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -190,6 +190,12 @@ async def test_turn_on_with_preset_mode_only( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -250,7 +256,7 @@ async def test_turn_on_with_preset_mode_and_speed( assert state.attributes[fan.ATTR_PERCENTAGE] == 0 assert state.attributes[fan.ATTR_PRESET_MODE] is None - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -258,6 +264,12 @@ async def test_turn_on_with_preset_mode_and_speed( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" + assert exc.value.translation_placeholders == { + "preset_mode": "invalid", + "preset_modes": "auto, smart, sleep, on", + } state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF @@ -343,7 +355,7 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No state = hass.states.get(fan_entity_id) assert state.state == STATE_OFF - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_PRESET_MODE, @@ -351,8 +363,10 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(ValueError): + with pytest.raises(fan.NotValidPresetModeError) as exc: await hass.services.async_call( fan.DOMAIN, SERVICE_TURN_ON, @@ -360,6 +374,8 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, fan_entity_id) -> No blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == fan.DOMAIN + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize("fan_entity_id", FULL_FAN_ENTITY_IDS) diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index e55a9b5b6b2..49912fd282f 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -259,7 +259,7 @@ async def test_connected_device_registered( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 # should be disabled assert len(entity_registry.entities) == 3 diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 67bc24909c5..2960789c646 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -123,7 +123,7 @@ async def test_reading_yaml_config( assert device.config_picture == config.config_picture assert device.consider_home == config.consider_home assert device.icon == config.icon - assert f"{device_tracker.DOMAIN}.test" in hass.config.components + assert f"test.{device_tracker.DOMAIN}" in hass.config.components @patch("homeassistant.components.device_tracker.const.LOGGER.warning") @@ -603,7 +603,7 @@ async def test_bad_platform(hass: HomeAssistant) -> None: with assert_setup_component(0, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, config) - assert f"{device_tracker.DOMAIN}.bad_platform" not in hass.config.components + assert f"bad_platform.{device_tracker.DOMAIN}" not in hass.config.components async def test_adding_unknown_device_to_config( diff --git a/tests/components/device_tracker/test_legacy.py b/tests/components/device_tracker/test_legacy.py new file mode 100644 index 00000000000..d7a2f33c23b --- /dev/null +++ b/tests/components/device_tracker/test_legacy.py @@ -0,0 +1,44 @@ +"""Tests for the legacy device tracker component.""" +from unittest.mock import mock_open, patch + +from homeassistant.components.device_tracker import legacy +from homeassistant.core import HomeAssistant +from homeassistant.util.yaml import dump + +from tests.common import patch_yaml_files + + +def test_remove_device_from_config(hass: HomeAssistant): + """Test the removal of a device from a config.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + "test2": { + "hide_if_away": True, + "mac": "00:ab:cd:33:44:55", + "name": "Test2", + "picture": "/local/test2.png", + "track": True, + }, + } + mopen = mock_open() + + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + with patch_yaml_files(files, True), patch( + "homeassistant.components.device_tracker.legacy.open", mopen + ): + legacy.remove_device_from_config(hass, "test") + + mopen().write.assert_called_once_with( + "test2:\n" + " hide_if_away: true\n" + " mac: 00:ab:cd:33:44:55\n" + " name: Test2\n" + " picture: /local/test2.png\n" + " track: true\n" + ) diff --git a/tests/components/discovergy/const.py b/tests/components/discovergy/const.py index 6c5428741af..5e596d7970f 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -67,7 +67,7 @@ LAST_READING = Reading( "energyOut": 55048723044000.0, "energyOut1": 0.0, "energyOut2": 0.0, - "power": 531750.0, + "power": 0.0, "power1": 142680.0, "power2": 138010.0, "power3": 251060.0, diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr index 2a7dd6903af..e8d4eab1909 100644 --- a/tests/components/discovergy/snapshots/test_diagnostics.ambr +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -61,7 +61,7 @@ 'energyOut': 55048723044000.0, 'energyOut1': 0.0, 'energyOut2': 0.0, - 'power': 531750.0, + 'power': 0.0, 'power1': 142680.0, 'power2': 138010.0, 'power3': 251060.0, diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index 981d1119a93..2473af5012a 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -132,7 +132,7 @@ 'entity_id': 'sensor.electricity_teststrasse_1_total_power', 'last_changed': , 'last_updated': , - 'state': '531.75', + 'state': '0.0', }) # --- # name: test_sensor[gas last transmitted] diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 55395b92270..422bfa0c35c 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -335,7 +335,7 @@ async def test_setup_serial_fail( # override the mock to have it fail the first time and succeed after first_fail_connection_factory = AsyncMock( return_value=(transport, protocol), - side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)), + side_effect=chain([serial.SerialException], repeat(DEFAULT)), ) assert result["type"] == "form" @@ -474,8 +474,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 512e0822016..b42f26f4ccc 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -98,8 +98,6 @@ async def test_migrate_unique_id( data={ "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py new file mode 100644 index 00000000000..5e31fa7a82e --- /dev/null +++ b/tests/components/dsmr/test_mbus_migration.py @@ -0,0 +1,208 @@ +"""Tests for the DSMR integration.""" +import datetime +from decimal import Decimal + +from homeassistant.components.dsmr.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_migrate_gas_to_mbus( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + dev_entities = er.async_entries_for_device( + entity_registry, device.id, include_disabled_entities=True + ) + assert not dev_entities + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + is None + ) + assert ( + entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, "37464C4F32313139303333373331" + ) + == "sensor.gas_meter_reading" + ) + + +async def test_migrate_gas_to_mbus_exists( + hass: HomeAssistant, entity_registry: er.EntityRegistry, dsmr_connection_fixture +) -> None: + """Test migration of unique_id.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="/dev/ttyUSB0", + data={ + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "serial_id": "1234", + "serial_id_gas": "37464C4F32313139303333373331", + }, + options={ + "time_between_update": 0, + }, + ) + + mock_entry.add_to_hass(hass) + + old_unique_id = "37464C4F32313139303333373331_belgium_5min_gas_meter_reading" + + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, mock_entry.entry_id)}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device.id, + unique_id=old_unique_id, + config_entry=mock_entry, + ) + assert entity.unique_id == old_unique_id + + device2 = device_registry.async_get_or_create( + config_entry_id=mock_entry.entry_id, + identifiers={(DOMAIN, "37464C4F32313139303333373331")}, + name="Gas Meter", + ) + await hass.async_block_till_done() + + entity_registry.async_get_or_create( + suggested_object_id="gas_meter_reading_alt", + disabled_by=None, + domain=SENSOR_DOMAIN, + platform=DOMAIN, + device_id=device2.id, + unique_id="37464C4F32313139303333373331", + config_entry=mock_entry, + ) + await hass.async_block_till_done() + + telegram = { + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + } + + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, old_unique_id) + == "sensor.gas_meter_reading" + ) diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 1895dd15dd1..419b562f431 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -8,19 +8,8 @@ import asyncio import datetime from decimal import Decimal from itertools import chain, repeat -from typing import Literal from unittest.mock import DEFAULT, MagicMock -from dsmr_parser.obis_references import ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS4_METER_READING2, -) import pytest from homeassistant import config_entries @@ -35,6 +24,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -61,8 +51,6 @@ async def test_default_setup( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -145,8 +133,8 @@ async def test_default_setup( # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # ensure entities have new state value after incoming telegram power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") @@ -199,8 +187,6 @@ async def test_setup_only_energy( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", } entry_options = { @@ -255,8 +241,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "4", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -321,7 +305,17 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) -async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: +@pytest.mark.parametrize( + ("value", "state"), + [ + (Decimal(745.690), "745.69"), + (Decimal(745.695), "745.695"), + (Decimal(0.000), STATE_UNKNOWN), + ], +) +async def test_v5_meter( + hass: HomeAssistant, dsmr_connection_fixture, value: Decimal, state: str +) -> None: """Test if v5 meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture @@ -334,8 +328,6 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -348,7 +340,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": value, "unit": "m3"}, ], ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( @@ -384,7 +376,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == state assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) @@ -410,8 +402,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5L", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -495,10 +485,18 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No from dsmr_parser.obis_references import ( BELGIUM_CURRENT_AVERAGE_DEMAND, BELGIUM_MAXIMUM_DEMAND_MONTH, + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING1, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, BELGIUM_MBUS3_METER_READING2, - BELGIUM_MBUS4_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING1, ELECTRICITY_ACTIVE_TARIFF, ) from dsmr_parser.objects import CosemObject, MBusObject @@ -506,44 +504,14 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - BELGIUM_MBUS1_METER_READING2: MBusObject( - BELGIUM_MBUS1_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], - ), - BELGIUM_MBUS2_METER_READING2: MBusObject( - BELGIUM_MBUS2_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], - ), - BELGIUM_MBUS3_METER_READING2: MBusObject( - BELGIUM_MBUS3_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, - ], - ), - BELGIUM_MBUS4_METER_READING2: MBusObject( - BELGIUM_MBUS4_METER_READING2, - [ - {"value": datetime.datetime.fromtimestamp(1551642216)}, - {"value": Decimal(745.698), "unit": "m3"}, - ], - ), BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( BELGIUM_CURRENT_AVERAGE_DEMAND, [{"value": Decimal(1.75), "unit": "kW"}], @@ -555,6 +523,62 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No {"value": Decimal(4.11), "unit": "kW"}, ], ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS1_METER_READING2: MBusObject( + BELGIUM_MBUS1_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING1: MBusObject( + BELGIUM_MBUS2_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(678.695), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642215)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), @@ -600,7 +624,7 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No assert max_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT assert max_demand.attributes.get(ATTR_STATE_CLASS) is None - # check if gas consumption is parsed correctly + # check if gas consumption mbus1 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS @@ -613,81 +637,135 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "678.695" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) -@pytest.mark.parametrize( - ("key1", "key2", "key3", "gas_value"), - [ - ( - BELGIUM_MBUS1_METER_READING1, - BELGIUM_MBUS2_METER_READING2, - BELGIUM_MBUS3_METER_READING1, - "745.696", - ), - ( - BELGIUM_MBUS1_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING2, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING1, - "745.695", - ), - ( - BELGIUM_MBUS4_METER_READING1, - BELGIUM_MBUS2_METER_READING1, - BELGIUM_MBUS3_METER_READING2, - "745.697", - ), - ], -) -async def test_belgian_meter_alt( - hass: HomeAssistant, - dsmr_connection_fixture, - key1: Literal, - key2: Literal, - key3: Literal, - gas_value: str, -) -> None: + # check if gas consumption mbus1 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "12.12" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if water usage mbus2 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.objects import MBusObject + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS1_METER_READING1, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_METER_READING2, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING1, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS4_METER_READING2, + ) + from dsmr_parser.objects import CosemObject, MBusObject entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", - "serial_id_gas": "5678", + "serial_id_gas": None, } entry_options = { "time_between_update": 0, } telegram = { - key1: MBusObject( - key1, - [ - {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, - ], + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "007", "unit": ""}] ), - key2: MBusObject( - key2, - [ - {"value": datetime.datetime.fromtimestamp(1551642214)}, - {"value": Decimal(745.696), "unit": "m3"}, - ], + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], ), - key3: MBusObject( - key3, + BELGIUM_MBUS1_METER_READING1: MBusObject( + BELGIUM_MBUS1_METER_READING1, [ {"value": datetime.datetime.fromtimestamp(1551642215)}, - {"value": Decimal(745.697), "unit": "m3"}, + {"value": Decimal(123.456), "unit": "m3"}, + ], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS2_METER_READING2: MBusObject( + BELGIUM_MBUS2_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642216)}, + {"value": Decimal(678.901), "unit": "m3"}, + ], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING1: MBusObject( + BELGIUM_MBUS3_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS4_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373334", "unit": ""}], + ), + BELGIUM_MBUS4_METER_READING2: MBusObject( + BELGIUM_MBUS4_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, ], ), } @@ -709,9 +787,24 @@ async def test_belgian_meter_alt( # after receiving telegram entities need to have the chance to be created await hass.async_block_till_done() - # check if gas consumption is parsed correctly + # check if water usage mbus1 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "123.456" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus2 is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == gas_value + assert gas_consumption.state == "678.901" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) @@ -722,6 +815,155 @@ async def test_belgian_meter_alt( == UnitOfVolume.CUBIC_METERS ) + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption.state == "12.12" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption.state == "13.13" + assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + gas_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + + +async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) -> None: + """Test if Belgian meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + BELGIUM_MBUS1_DEVICE_TYPE, + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS2_DEVICE_TYPE, + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_DEVICE_TYPE, + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + BELGIUM_MBUS3_METER_READING2, + BELGIUM_MBUS4_DEVICE_TYPE, + BELGIUM_MBUS4_METER_READING1, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5B", + "serial_id": "1234", + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = { + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0003", "unit": ""}] + ), + BELGIUM_MBUS1_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS1_DEVICE_TYPE, [{"value": "006", "unit": ""}] + ), + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS1_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373331", "unit": ""}], + ), + BELGIUM_MBUS2_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS2_DEVICE_TYPE, [{"value": "003", "unit": ""}] + ), + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS2_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373332", "unit": ""}], + ), + BELGIUM_MBUS3_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS3_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER: CosemObject( + BELGIUM_MBUS3_EQUIPMENT_IDENTIFIER, + [{"value": "37464C4F32313139303333373333", "unit": ""}], + ), + BELGIUM_MBUS3_METER_READING2: MBusObject( + BELGIUM_MBUS3_METER_READING2, + [ + {"value": datetime.datetime.fromtimestamp(1551642217)}, + {"value": Decimal(12.12), "unit": "m3"}, + ], + ), + BELGIUM_MBUS4_DEVICE_TYPE: CosemObject( + BELGIUM_MBUS4_DEVICE_TYPE, [{"value": "007", "unit": ""}] + ), + BELGIUM_MBUS4_METER_READING1: MBusObject( + BELGIUM_MBUS4_METER_READING1, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(13.13), "unit": "m3"}, + ], + ), + } + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + # tariff should be translated in human readable and have no unit + active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") + assert active_tariff.state == "unknown" + + # check if gas consumption mbus2 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") + assert gas_consumption is None + + # check if water usage mbus3 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption_2") + assert water_consumption is None + + # check if gas consumption mbus4 is parsed correctly + gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption_2") + assert gas_consumption is None + + # check if gas consumption mbus4 is parsed correctly + water_consumption = hass.states.get("sensor.water_meter_water_consumption") + assert water_consumption.state == "13.13" + assert ( + water_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WATER + ) + assert ( + water_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + water_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfVolume.CUBIC_METERS + ) + async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test if Belgian meter is correctly parsed.""" @@ -733,8 +975,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -788,8 +1028,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5S", - "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -863,8 +1101,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", - "precision": 4, - "reconnect_interval": 30, "serial_id": None, "serial_id_gas": None, } @@ -904,7 +1140,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") - assert active_tariff.state == "54184.6316" + assert active_tariff.state == "54184.632" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert active_tariff.attributes.get(ATTR_ICON) is None assert ( @@ -917,7 +1153,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") - assert active_tariff.state == "19981.1069" + assert active_tariff.state == "19981.107" assert ( active_tariff.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -937,8 +1173,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -965,8 +1199,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "port": "1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } @@ -984,6 +1216,7 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - assert connection_factory.call_args_list[0][0][1] == "1234" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_connection_errors_retry( hass: HomeAssistant, dsmr_connection_fixture ) -> None: @@ -993,8 +1226,6 @@ async def test_connection_errors_retry( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1023,6 +1254,7 @@ async def test_connection_errors_retry( assert first_fail_connection_factory.call_count >= 2, "connecting not retried" +@patch("homeassistant.components.dsmr.sensor.DEFAULT_RECONNECT_INTERVAL", 0) async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" from dsmr_parser.obis_references import ( @@ -1036,8 +1268,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 0, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1119,8 +1349,6 @@ async def test_gas_meter_providing_energy_reading( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, - "reconnect_interval": 30, "serial_id": "1234", "serial_id_gas": "5678", } diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f4a1f661f9b..522bbe5af06 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -877,6 +877,114 @@ async def test_cost_sensor_handle_price_units( assert state.state == "20.0" +async def test_cost_sensor_handle_late_price_sensor( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test energy cost where the price sensor is not immediately available.""" + energy_attributes = { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + } + price_attributes = { + ATTR_UNIT_OF_MEASUREMENT: f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + } + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": "sensor.energy_price", + "number_energy_price": None, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Initial state: 10kWh, price sensor not yet available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 10, + energy_attributes, + ) + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped by 10 kWh, price sensor still not yet available + hass.states.async_set( + "sensor.energy_consumption", + 20, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped by 10 kWh, price sensor now available + hass.states.async_set("sensor.energy_price", "1", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 30, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "20.0" + + # Energy use bumped by 10 kWh, price sensor available + hass.states.async_set( + "sensor.energy_consumption", + 40, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "30.0" + + # Energy use bumped by 10 kWh, price sensor no longer available + hass.states.async_set("sensor.energy_price", "unknown", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 50, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "30.0" + + # Energy use bumped by 10 kWh, price sensor again available + hass.states.async_set("sensor.energy_price", "2", price_attributes) + hass.states.async_set( + "sensor.energy_consumption", + 60, + energy_attributes, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "70.0" + + @pytest.mark.parametrize( "unit", (UnitOfVolume.CUBIC_FEET, UnitOfVolume.CUBIC_METERS), diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py index 8d6af04c174..874a12173d6 100644 --- a/tests/components/epson/test_media_player.py +++ b/tests/components/epson/test_media_player.py @@ -30,9 +30,9 @@ async def test_set_unique_id( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.unique_id is None - entity = entity_registry.async_get("media_player.epson") - assert entity - assert entity.unique_id == entry.entry_id + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == entry.entry_id with patch( "homeassistant.components.epson.Projector.get_power", return_value="01" ), patch( @@ -43,7 +43,7 @@ async def test_set_unique_id( freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done() - entity = entity_registry.async_get("media_player.epson") - assert entity - assert entity.unique_id == "123" + entity_entry = entity_registry.async_get("media_player.epson") + assert entity_entry + assert entity_entry.unique_id == "123" assert entry.unique_id == "123" diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 8f0b8f96c56..065890fd623 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -15,7 +15,6 @@ from aioesphomeapi import ( ) from homeassistant.components.climate import ( - ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_FAN_MODE, ATTR_HUMIDITY, @@ -29,7 +28,6 @@ from homeassistant.components.climate import ( ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, FAN_HIGH, - SERVICE_SET_AUX_HEAT, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, @@ -39,7 +37,7 @@ from homeassistant.components.climate import ( SWING_BOTH, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -321,68 +319,6 @@ async def test_climate_entity_with_step_and_target_temp( mock_client.climate_command.reset_mock() -async def test_climate_entity_with_aux_heat( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry -) -> None: - """Test a generic climate entity with aux heat.""" - entity_info = [ - ClimateInfo( - object_id="myclimate", - key=1, - name="my climate", - unique_id="my_climate", - supports_current_temperature=True, - supports_two_point_target_temperature=True, - supports_action=True, - visual_min_temperature=10.0, - visual_max_temperature=30.0, - supports_aux_heat=True, - ) - ] - states = [ - ClimateState( - key=1, - mode=ClimateMode.HEAT, - action=ClimateAction.HEATING, - current_temperature=30, - target_temperature=20, - fan_mode=ClimateFanMode.AUTO, - swing_mode=ClimateSwingMode.BOTH, - aux_heat=True, - ) - ] - user_service = [] - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=entity_info, - user_service=user_service, - states=states, - ) - state = hass.states.get("climate.test_myclimate") - assert state is not None - assert state.state == HVACMode.HEAT - attributes = state.attributes - assert attributes[ATTR_AUX_HEAT] == STATE_ON - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: False}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=False)]) - mock_client.climate_command.reset_mock() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_AUX_HEAT: True}, - blocking=True, - ) - mock_client.climate_command.assert_has_calls([call(key=1, aux_heat=True)]) - mock_client.climate_command.reset_mock() - - async def test_climate_entity_with_humidity( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry ) -> None: diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 99f4bbc86a9..6f383dcb6ba 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -16,12 +16,14 @@ from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN, SERVICE_DECREASE_SPEED, SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, @@ -145,6 +147,7 @@ async def test_fan_entity_with_all_features_new_api( supports_direction=True, supports_speed=True, supports_oscillation=True, + supported_preset_modes=["Preset1", "Preset2"], ) ] states = [ @@ -154,6 +157,7 @@ async def test_fan_entity_with_all_features_new_api( oscillating=True, speed_level=3, direction=FanDirection.REVERSE, + preset_mode=None, ) ] user_service = [] @@ -270,6 +274,15 @@ async def test_fan_entity_with_all_features_new_api( ) mock_client.fan_command.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.reset_mock() + async def test_fan_entity_with_no_features_new_api( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry @@ -285,6 +298,7 @@ async def test_fan_entity_with_no_features_new_api( supports_direction=False, supports_speed=False, supports_oscillation=False, + supported_preset_modes=[], ) ] states = [FanState(key=1, state=True)] diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index ca74c99f0cd..38a33bfdec2 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -337,6 +337,28 @@ async def test_send_tts_called( mock_send_tts.assert_called_with(_TEST_MEDIA_ID) +async def test_send_tts_not_called_when_empty( + hass: HomeAssistant, + voice_assistant_udp_server_v1: VoiceAssistantUDPServer, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server with a v1/v2 device doesn't call _send_tts when the output is empty.""" + with patch( + "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts" + ) as mock_send_tts: + voice_assistant_udp_server_v1._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + voice_assistant_udp_server_v2._event_callback( + PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) + ) + + mock_send_tts.assert_not_called() + + async def test_send_tts( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index 8338afc9c68..ec421141768 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,8 +1,19 @@ """Tests for fan platforms.""" import pytest -from homeassistant.components.fan import FanEntity +from homeassistant.components.fan import ( + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN, + SERVICE_SET_PRESET_MODE, + FanEntity, + NotValidPresetModeError, +) from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test.fan import MockFan class BaseFan(FanEntity): @@ -82,3 +93,55 @@ def test_fanentity_attributes(attribute_name, attribute_value) -> None: fan = BaseFan() setattr(fan, f"_attr_{attribute_name}", attribute_value) assert getattr(fan, attribute_name) == attribute_value + + +async def test_preset_mode_validation( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + enable_custom_integrations: None, +) -> None: + """Test preset mode validation.""" + + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.fan") + platform.init(empty=False) + + assert await async_setup_component(hass, "fan", {"fan": {"platform": "test"}}) + await hass.async_block_till_done() + + test_fan: MockFan = platform.ENTITIES["support_preset_mode"] + await hass.async_block_till_done() + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODES) == ["auto", "eco"] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "eco", + }, + blocking=True, + ) + + state = hass.states.get("fan.support_fan_with_preset_mode_support") + assert state.attributes.get(ATTR_PRESET_MODE) == "eco" + + with pytest.raises(NotValidPresetModeError) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "fan.support_fan_with_preset_mode_support", + "preset_mode": "invalid", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_preset_mode" + + with pytest.raises(NotValidPresetModeError) as exc: + await test_fan._valid_preset_mode_or_raise("invalid") + assert exc.value.translation_key == "not_valid_preset_mode" diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py index d4bf159c5c9..474455fc164 100644 --- a/tests/components/firmata/test_config_flow.py +++ b/tests/components/firmata/test_config_flow.py @@ -31,7 +31,7 @@ async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -48,7 +48,7 @@ async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialTimeoutException, + side_effect=serial.SerialTimeoutException, ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index b6bf75c1c69..3ed3695ff3d 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -107,18 +107,21 @@ async def test_token_refresh_success( @pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, status=HTTPStatus.UNAUTHORIZED, + closing=closing, ) assert not await integration_setup() diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..4cae6e4f4bf --- /dev/null +++ b/tests/components/flexit_bacnet/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flexit Nordic (BACnet) integration.""" diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py new file mode 100644 index 00000000000..b136b134e01 --- /dev/null +++ b/tests/components/flexit_bacnet/conftest.py @@ -0,0 +1,44 @@ +"""Configuration for Flexit Nordic (BACnet) tests.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +async def flow_id(hass: HomeAssistant) -> str: + """Return initial ID for user-initiated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + return result["flow_id"] + + +@pytest.fixture(autouse=True) +def mock_serial_number_and_device_name(): + """Mock serial number of the device.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", + "0000-0001", + ), patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", + "Device Name", + ): + yield + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py new file mode 100644 index 00000000000..ed513587af6 --- /dev/null +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the Flexit Nordic (BACnet) config flow.""" +import asyncio.exceptions +from unittest.mock import patch + +from flexit_bacnet import DecodingError +import pytest + +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None: + """Test we get the form and the happy path works.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Device Name" + assert result["context"]["unique_id"] == "0000-0001" + assert result["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + asyncio.exceptions.TimeoutError, + "cannot_connect", + ), + (ConnectionError, "cannot_connect"), + (DecodingError, "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_fails( + hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry +) -> None: + """Test that we return 'cannot_connect' error when attempting to connect to an incorrect IP address. + + The flexit_bacnet library raises asyncio.exceptions.TimeoutError in that scenario. + """ + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + assert len(mock_setup_entry.mock_calls) == 0 + + # ensure that user can recover from this error + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Device Name" + assert result2["context"]["unique_id"] == "0000-0001" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: + """Test that we cannot add already added device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 5c8d30772f0..b8273204325 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -296,7 +296,7 @@ async def test_remove_device( ) response = await ws_client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" await hass.async_block_till_done() # try to delete orphan_device diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index da318b1a94d..670d3efce51 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -72,10 +72,10 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") assert state is not None assert int(state.state) == 3 - assert state.name == "GDACS (32.87336, -117.22743)" + assert state.name == "32.87336, -117.22743" attributes = state.attributes assert attributes[ATTR_STATUS] == "OK" assert attributes[ATTR_CREATED] == 3 @@ -96,7 +96,7 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_CREATED] == 1 assert attributes[ATTR_UPDATED] == 2 @@ -125,6 +125,6 @@ async def test_setup(hass: HomeAssistant) -> None: == 1 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_REMOVED] == 3 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index aecfcbc29c1..8bfd0a66dd5 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,7 +1,6 @@ """The tests for generic camera component.""" import asyncio from http import HTTPStatus -import sys from unittest.mock import patch import aiohttp @@ -164,17 +163,10 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "5") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "asyncio.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") - else: - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "async_timeout.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "asyncio.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index a70cd8aee9f..8466f5ad4eb 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1301,6 +1301,7 @@ async def test_event_differs_timezone( } +@pytest.mark.freeze_time("2023-11-30 12:15:00 +00:00") async def test_invalid_rrule_fix( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 771df137278..aaa3949caaf 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -89,6 +89,7 @@ async def test_google_entity_sync_serialize_with_matter( manufacturer="Someone", model="Some model", sw_version="Some Version", + identifiers={("matter", "12345678")}, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) entity = entity_registry.async_get_or_create( diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 73289b313d9..7d6eb920593 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -1,11 +1,29 @@ # serializer version: 1 -# name: test_create_todo_list_item[api_responses0] +# name: test_create_todo_list_item[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', 'POST', ) # --- -# name: test_create_todo_list_item[api_responses0].1 +# name: test_create_todo_list_item[description].1 + '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' +# --- +# name: test_create_todo_list_item[due] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[due].1 + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_create_todo_list_item[summary] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[summary].1 '{"title": "Soda", "status": "needsAction"}' # --- # name: test_delete_todo_list_item[_handler] @@ -38,6 +56,33 @@ }), ]) # --- +# name: test_partial_update[description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[description].1 + '{"notes": "6-pack"}' +# --- +# name: test_partial_update[due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[due_date].1 + '{"due": "2023-11-18T00:00:00-08:00"}' +# --- +# name: test_partial_update[rename] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[rename].1 + '{"title": "Soda"}' +# --- # name: test_partial_update_status[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -47,15 +92,6 @@ # name: test_partial_update_status[api_responses0].1 '{"status": "needsAction"}' # --- -# name: test_partial_update_title[api_responses0] - tuple( - 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', - 'PATCH', - ) -# --- -# name: test_partial_update_title[api_responses0].1 - '{"title": "Soda"}' -# --- # name: test_update_todo_list_item[api_responses0] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 0b82815b33a..3329f89c1ca 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -19,13 +19,12 @@ from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator ENTITY_ID = "todo.my_tasks" +ITEM = { + "id": "task-list-id-1", + "title": "My tasks", +} LIST_TASK_LIST_RESPONSE = { - "items": [ - { - "id": "task-list-id-1", - "title": "My tasks", - }, - ] + "items": [ITEM], } EMPTY_RESPONSE = {} LIST_TASKS_RESPONSE = { @@ -76,6 +75,20 @@ LIST_TASKS_RESPONSE_MULTIPLE = { ], } +# API responses when testing update methods +UPDATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_WATER, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update +] +CREATE_API_RESPONSES = [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh +] + @pytest.fixture def platforms() -> list[str]: @@ -207,12 +220,14 @@ def mock_http_response(response_handler: list | Callable) -> Mock: "title": "Task 1", "status": "needsAction", "position": "0000000000000001", + "due": "2023-11-18T00:00:00+00:00", }, { "id": "task-2", "title": "Task 2", "status": "completed", "position": "0000000000000002", + "notes": "long description", }, ], }, @@ -238,11 +253,13 @@ async def test_get_items( "uid": "task-1", "summary": "Task 1", "status": "needs_action", + "due": "2023-11-18", }, { "uid": "task-2", "summary": "Task 2", "status": "completed", + "description": "long description", }, ] @@ -333,21 +350,20 @@ async def test_task_items_error_response( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, - EMPTY_RESPONSE, # create - LIST_TASKS_RESPONSE, # refresh after delete - ] + (CREATE_API_RESPONSES, {}), + (CREATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (CREATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=["summary", "due", "description"], ) async def test_create_todo_list_item( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Mock, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for creating a To-do Item.""" @@ -361,7 +377,7 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -407,17 +423,7 @@ async def test_create_todo_list_item_error( ) -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_update_todo_list_item( hass: HomeAssistant, setup_credentials: None, @@ -483,21 +489,20 @@ async def test_update_todo_list_item_error( @pytest.mark.parametrize( - "api_responses", + ("api_responses", "item_data"), [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] + (UPDATE_API_RESPONSES, {"rename": "Soda"}), + (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), + (UPDATE_API_RESPONSES, {"description": "6-pack"}), ], + ids=("rename", "due_date", "description"), ) -async def test_partial_update_title( +async def test_partial_update( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], mock_http_response: Any, + item_data: dict[str, Any], snapshot: SnapshotAssertion, ) -> None: """Test for partial update with title only.""" @@ -511,7 +516,7 @@ async def test_partial_update_title( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "some-task-id", "rename": "Soda"}, + {"item": "some-task-id", **item_data}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -522,17 +527,7 @@ async def test_partial_update_title( assert call.kwargs.get("body") == snapshot -@pytest.mark.parametrize( - "api_responses", - [ - [ - LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE_WATER, - EMPTY_RESPONSE, # update - LIST_TASKS_RESPONSE, # refresh after update - ] - ], -) +@pytest.mark.parametrize("api_responses", [UPDATE_API_RESPONSES]) async def test_partial_update_status( hass: HomeAssistant, setup_credentials: None, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 568b98daec1..e28582ca2e9 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -98,7 +98,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.fake_device_1', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -107,7 +107,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'fake-device-1', + 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index d2b0a5fbf4e..eff96ba1bd3 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Panel Light', + 'friendly_name': 'fake-device-1 Panel light', 'icon': 'mdi:lightbulb', }), 'context': , @@ -27,7 +27,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Fresh Air', + 'friendly_name': 'fake-device-1 Fresh air', }), 'context': , 'entity_id': 'switch.fake_device_1_fresh_air', @@ -74,7 +74,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_panel_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,11 +83,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:lightbulb', - 'original_name': 'fake-device-1 Panel Light', + 'original_name': 'Panel light', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'aabbcc112233_Panel Light', 'unit_of_measurement': None, }), @@ -103,7 +103,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_quiet', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -112,11 +112,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Quiet', + 'original_name': 'Quiet', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'quiet', 'unique_id': 'aabbcc112233_Quiet', 'unit_of_measurement': None, }), @@ -132,7 +132,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_fresh_air', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -141,11 +141,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Fresh Air', + 'original_name': 'Fresh air', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fresh_air', 'unique_id': 'aabbcc112233_Fresh Air', 'unit_of_measurement': None, }), @@ -161,7 +161,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_xfan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,11 +170,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 XFan', + 'original_name': 'XFan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'xfan', 'unique_id': 'aabbcc112233_XFan', 'unit_of_measurement': None, }), @@ -190,7 +190,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_health_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -199,11 +199,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:pine-tree', - 'original_name': 'fake-device-1 Health mode', + 'original_name': 'Health mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'health_mode', 'unique_id': 'aabbcc112233_Health mode', 'unit_of_measurement': None, }), diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index b13544fd3f7..f40ab6525d4 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -1,7 +1,7 @@ """Tests for gree component.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN @@ -24,7 +24,7 @@ def mock_now(): async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" mock_device_1 = build_device_mock( @@ -58,8 +58,8 @@ async def test_discovery_after_setup( device.side_effect = [mock_device_1, mock_device_2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 82ad75b5d28..f5af1f403c3 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest @@ -115,7 +116,7 @@ async def test_discovery_setup_connection_error( async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" MockDevice1 = build_device_mock( @@ -142,8 +143,8 @@ async def test_discovery_after_setup( device.side_effect = [MockDevice1, MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -151,7 +152,7 @@ async def test_discovery_after_setup( async def test_discovery_add_device_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after initial setup.""" MockDevice1 = build_device_mock( @@ -178,8 +179,8 @@ async def test_discovery_add_device_after_setup( device.side_effect = [MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -187,7 +188,7 @@ async def test_discovery_add_device_after_setup( async def test_discovery_device_bind_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after a late device bind.""" MockDevice1 = build_device_mock( @@ -212,15 +213,17 @@ async def test_discovery_device_bind_after_setup( MockDevice1.update_state.side_effect = None next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state != STATE_UNAVAILABLE -async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) -> None: +async def test_update_connection_failure( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, device, mock_now +) -> None: """Testing update hvac connection failure exception.""" device().update_state.side_effect = [ DEFAULT_MOCK, @@ -231,8 +234,8 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) await async_setup_gree(hass) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # First update to make the device available @@ -241,13 +244,13 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() next_update = mock_now + timedelta(minutes=15) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # Then two more update failures to make the device unavailable @@ -257,7 +260,7 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) async def test_update_connection_failure_recovery( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection failure recovery.""" device().update_state.side_effect = [ @@ -270,8 +273,8 @@ async def test_update_connection_failure_recovery( # First update becomes unavailable next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -280,8 +283,8 @@ async def test_update_connection_failure_recovery( # Second update restores the connection next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -290,7 +293,7 @@ async def test_update_connection_failure_recovery( async def test_update_unhandled_exception( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection unhandled response exception.""" device().update_state.side_effect = [DEFAULT_MOCK, Exception] @@ -302,8 +305,8 @@ async def test_update_unhandled_exception( assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -312,15 +315,15 @@ async def test_update_unhandled_exception( async def test_send_command_device_timeout( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test for sending power on command to the device with a device timeout.""" await async_setup_gree(hass) # First update to make the device available next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 3189e344c62..7b83ed9eb0d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -699,4 +699,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/holiday/__init__.py b/tests/components/holiday/__init__.py new file mode 100644 index 00000000000..e906586aabc --- /dev/null +++ b/tests/components/holiday/__init__.py @@ -0,0 +1 @@ +"""Tests for the Holiday integration.""" diff --git a/tests/components/komfovent/conftest.py b/tests/components/holiday/conftest.py similarity index 68% rename from tests/components/komfovent/conftest.py rename to tests/components/holiday/conftest.py index d9cb0950c74..d9b0d1a5788 100644 --- a/tests/components/komfovent/conftest.py +++ b/tests/components/holiday/conftest.py @@ -1,4 +1,4 @@ -"""Common fixtures for the Komfovent tests.""" +"""Common fixtures for the Holiday tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch @@ -9,6 +9,6 @@ import pytest def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" with patch( - "homeassistant.components.komfovent.async_setup_entry", return_value=True + "homeassistant.components.holiday.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py new file mode 100644 index 00000000000..06011fb8e6b --- /dev/null +++ b/tests/components/holiday/test_calendar.py @@ -0,0 +1,229 @@ +"""Tests for calendar platform of Holiday integration.""" +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, +) +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test HolidayCalendarEntity functionality.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "US", CONF_PROVINCE: "AK"}, + title="United States, AK", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await async_setup_component(hass, "calendar", {}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + state = hass.states.get("calendar.united_states_ak") + assert state is not None + assert state.state == "on" + + # Test holidays for the next year + freezer.move_to(datetime(2023, 12, 31, 12, tzinfo=dt_util.UTC)) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now() + timedelta(days=1), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2024-01-01", + "end": "2024-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + +async def test_default_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default language.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "FR", CONF_PROVINCE: "BL"}, + title="France, BL", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test French calendar with English language + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "France, BL", + } + ] + } + } + + # Test French calendar with French language + hass.config.language = "fr" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Jour de l'an", + "location": "France, BL", + } + ] + } + } + + +async def test_no_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test language defaults to English if language not exist.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "AL"}, + title="Albania", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.albania", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.albania": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Albania", + } + ] + } + } + + +async def test_no_next_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "DE"}, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Move time to out of reach + freezer.move_to(datetime(dt_util.now().year + 5, 1, 1, 12, tzinfo=dt_util.UTC)) + async_fire_time_changed(hass) + + state = hass.states.get("calendar.germany") + assert state is not None + assert state.state == "off" + assert state.attributes == {"friendly_name": "Germany"} diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py new file mode 100644 index 00000000000..e99d310762e --- /dev/null +++ b/tests/components/holiday/test_config_flow.py @@ -0,0 +1,128 @@ +"""Test the Holiday config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Germany, BW" + assert result3["data"] == { + "country": "DE", + "province": "BW", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_no_subdivision(hass: HomeAssistant) -> None: + """Test we get the forms correctly without subdivision.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Sweden" + assert result2["data"] == { + "country": "SE", + } + + +async def test_form_translated_title(hass: HomeAssistant) -> None: + """Test the title gets translated.""" + hass.config.language = "de" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Schweden" + + +async def test_single_combination_country_province(hass: HomeAssistant) -> None: + """Test that configuring more than one instance is rejected.""" + data_de = { + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + } + data_se = { + CONF_COUNTRY: "SE", + } + MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass) + + # Test for country without subdivisions + result_se = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_se, + ) + assert result_se["type"] == FlowResultType.ABORT + assert result_se["reason"] == "already_configured" + + # Test for country with subdivisions + result_de_step1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_de, + ) + assert result_de_step1["type"] == FlowResultType.FORM + + result_de_step2 = await hass.config_entries.flow.async_configure( + result_de_step1["flow_id"], + { + CONF_PROVINCE: data_de[CONF_PROVINCE], + }, + ) + assert result_de_step2["type"] == FlowResultType.ABORT + assert result_de_step2["reason"] == "already_configured" diff --git a/tests/components/holiday/test_init.py b/tests/components/holiday/test_init.py new file mode 100644 index 00000000000..a044e390a68 --- /dev/null +++ b/tests/components/holiday/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Holiday integration.""" + +from homeassistant.components.holiday.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_DATA = { + "country": "Germany", + "province": "BW", +} + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test removing integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + state: ConfigEntryState = entry.state + assert state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index b4554f1a4e6..513827b5432 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -38,34 +39,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "5:00:00"}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", - "id": "{{ trigger.id}}", - }, + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", + "id": "{{ trigger.id}}", }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -79,7 +79,7 @@ async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: ("has_date", "has_time"), [(True, True), (True, False), (False, True)] ) async def test_if_fires_using_at_input_datetime( - hass: HomeAssistant, calls, has_date, has_time + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, has_date, has_time ) -> None: """Test for firing at input_datetime.""" await async_setup_component( @@ -107,24 +107,22 @@ async def test_if_fires_using_at_input_datetime( time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "input_datetime.trigger"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "input_datetime.trigger"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -161,7 +159,9 @@ async def test_if_fires_using_at_input_datetime( ) -async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_multiple_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() @@ -169,26 +169,23 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" - }, + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -203,7 +200,9 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: assert calls[1].data["some"] == "time - 6" -async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_using_wrong_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """YAML translates time values to total seconds. This should break the before rule. @@ -214,25 +213,23 @@ async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: year=now.year + 1, hour=1, minute=0, second=0 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - with assert_setup_component(1, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time", - "at": 3605, - # Total seconds. Hour = 3600 second - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() + freezer.move_to(time_that_will_not_match_right_away) + with assert_setup_component(1, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": 3605, + # Total seconds. Hour = 3600 second + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE async_fire_time_changed( @@ -409,7 +406,9 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: assert len(mock_track_time_change.mock_calls) == 3 -async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at_sensor( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -424,24 +423,22 @@ async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "sensor.next_alarm"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "sensor.next_alarm"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index e7a6a98bb96..0f6a075eb6e 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -1,7 +1,7 @@ """The tests for the time_pattern automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -27,33 +27,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_hour_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if hour is matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=3 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 0, - "minutes": "*", - "seconds": "*", - }, - "action": { - "service": "test.automation", - "data_template": {"id": "{{ trigger.id}}"}, - }, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 0, + "minutes": "*", + "seconds": "*", + }, + "action": { + "service": "test.automation", + "data_template": {"id": "{{ trigger.id}}"}, + }, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) await hass.async_block_till_done() @@ -72,30 +72,30 @@ async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: assert calls[0].data["id"] == 0 -async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_minute_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if minutes are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": 0, - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": 0, + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) @@ -103,30 +103,30 @@ async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_second_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": 0, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": 0, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) @@ -135,31 +135,29 @@ async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: async def test_if_fires_when_second_as_string_matches( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=15 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "30", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "30", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, time_that_will_not_match_right_away + timedelta(seconds=15) @@ -169,30 +167,30 @@ async def test_if_fires_when_second_as_string_matches( assert len(calls) == 1 -async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_all_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if everything matches.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=4 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 1, - "minutes": 2, - "seconds": 3, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 1, + "minutes": 2, + "seconds": 3, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) @@ -202,30 +200,30 @@ async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_seconds( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every second.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "/10", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "/10", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) @@ -235,31 +233,31 @@ async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: assert len(calls) >= 1 -async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_minutes( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every minute.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "/2", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "/2", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) @@ -269,30 +267,30 @@ async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_hours( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "/2", - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "/2", + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) @@ -302,25 +300,25 @@ async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_default_values(hass: HomeAssistant, calls) -> None: +async def test_default_values( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time_pattern", "minutes": "2"}, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time_pattern", "minutes": "2"}, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index fbc77cdee9e..f58d561bfb3 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -293,7 +293,14 @@ async def test_option_flow_install_multi_pan_addon_zha( config_entry.add_to_hass(hass) zha_config_entry = MockConfigEntry( - data={"device": {"path": "/dev/ttyTEST123"}, "radio_type": "ezsp"}, + data={ + "device": { + "path": "/dev/ttyTEST123", + "baudrate": 115200, + "flow_control": None, + }, + "radio_type": "ezsp", + }, domain=ZHA_DOMAIN, options={}, title="Test", @@ -348,8 +355,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 4d43d29463a..65636b27a16 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -337,8 +337,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index e00603dc8f7..11961c09a2d 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -147,7 +147,7 @@ async def test_setup_zha( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", @@ -200,8 +200,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", @@ -255,7 +255,7 @@ async def test_setup_zha_multipan_other_device( assert config_entry.data == { "device": { "baudrate": 115200, - "flow_control": "software", + "flow_control": None, "path": CONFIG_ENTRY_DATA["device"], }, "radio_type": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 58d47c41987..242b316de66 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -249,8 +249,8 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.data == { "device": { "path": "socket://core-silabs-multiprotocol:9999", - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, }, "radio_type": "ezsp", } diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index addc519c865..f8cdcd8a13b 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -145,8 +145,8 @@ async def test_setup_zha_multipan( config_entry = hass.config_entries.async_entries("zha")[0] assert config_entry.data == { "device": { - "baudrate": 57600, # ZHA default - "flow_control": "software", # ZHA default + "baudrate": 115200, + "flow_control": None, "path": "socket://core-silabs-multiprotocol:9999", }, "radio_type": "ezsp", diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index e778c82928b..0c24d9daebe 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,6 +1,5 @@ """Fixtures for HomeWizard integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.errors import NotFoundError @@ -11,7 +10,7 @@ from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @pytest.fixture @@ -35,22 +34,22 @@ def mock_homewizardenergy( client = homewizard.return_value client.device.return_value = Device.from_dict( - json.loads(load_fixture(f"{device_fixture}/device.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) ) client.data.return_value = Data.from_dict( - json.loads(load_fixture(f"{device_fixture}/data.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) ) if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): client.state.return_value = State.from_dict( - json.loads(load_fixture(f"{device_fixture}/state.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) ) else: client.state.side_effect = NotFoundError if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): client.system.return_value = System.from_dict( - json.loads(load_fixture(f"{device_fixture}/system.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) ) else: client.system.side_effect = NotFoundError diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 436abc70ac1..5c7e71ea9ac 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'entity_id': 'number.device_status_light_brightness', 'last_changed': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_entities[HWE-SKT].1 diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 7dab8cfbb06..a4893c77f42 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry @@ -118,3 +120,104 @@ async def test_load_handles_homewizardenergy_exception( ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_ERROR, ) + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are migrated.""" + mock_config_entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + assert entity.unique_id == old_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id + assert entity_migrated.previous_unique_id == old_unique_id + + +@pytest.mark.parametrize( + ("device_fixture", "old_unique_id", "new_unique_id"), + [ + ( + "HWE-SKT", + "aabbccddeeff_total_power_import_t1_kwh", + "aabbccddeeff_total_power_import_kwh", + ), + ( + "HWE-SKT", + "aabbccddeeff_total_power_export_t1_kwh", + "aabbccddeeff_total_power_export_kwh", + ), + ], +) +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_sensor_migration_does_not_trigger( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test total power T1 sensors are not migrated when not possible.""" + mock_config_entry.add_to_hass(hass) + + old_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=mock_config_entry, + ) + + new_entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=new_unique_id, + config_entry=mock_config_entry, + ) + + assert old_entity.unique_id == old_unique_id + assert new_entity.unique_id == new_unique_id + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = entity_registry.async_get(old_entity.entity_id) + assert entity + assert entity.unique_id == old_unique_id + assert entity.previous_unique_id is None + + entity = entity_registry.async_get(new_entity.entity_id) + assert entity + assert entity.unique_id == new_unique_id + assert entity.previous_unique_id is None diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ebd8d80ece2..a54f98899c6 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -41,7 +41,7 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100" + assert state.state == "100.0" mock_homewizardenergy.state.return_value.brightness = None @@ -64,7 +64,7 @@ async def test_number_entities( ) assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=127) + mock_homewizardenergy.state_set.assert_called_with(brightness=129) mock_homewizardenergy.state_set.side_effect = RequestError with pytest.raises( @@ -97,7 +97,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630"]) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: - """Does not load button when device has no support for it.""" + """Does not load number when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 2f6e777a3a8..61ca34fab7a 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -29,6 +29,13 @@ pytestmark = [ @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ + ( + "HWE-P1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ( "HWE-WTR", [ diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5a5bffe6748..97e39811cd8 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -5,8 +5,7 @@ from http import HTTPStatus from ipaddress import ip_network import logging from pathlib import Path -import time -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest @@ -21,7 +20,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator @@ -501,22 +499,3 @@ async def test_logging( response = await client.get("/api/states/logging.entity") assert response.status == HTTPStatus.OK assert "GET /api/states/logging.entity" not in caplog.text - - -async def test_hass_access_logger_at_info_level( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that logging happens at info level.""" - test_logger = logging.getLogger("test.aiohttp.logger") - logger = http.HomeAssistantAccessLogger(test_logger) - mock_request = MagicMock() - response = AiohttpClientMockResponse( - "POST", "http://127.0.0.1", status=HTTPStatus.OK - ) - setattr(response, "body_length", 42) - logger.log(mock_request, response, time.time()) - assert "42" in caplog.text - caplog.clear() - test_logger.setLevel(logging.WARNING) - logger.log(mock_request, response, time.time()) - assert "42" not in caplog.text diff --git a/tests/components/huawei_lte/test_select.py b/tests/components/huawei_lte/test_select.py new file mode 100644 index 00000000000..c3f6ded65b6 --- /dev/null +++ b/tests/components/huawei_lte/test_select.py @@ -0,0 +1,43 @@ +"""Tests for the Huawei LTE selects.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.select.const import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +SELECT_NETWORK_MODE = "select.lte_preferred_network_mode" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_set_net_mode(client, hass: HomeAssistant) -> None: + """Test setting network mode.""" + client.return_value = magic_client({}) + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: SELECT_NETWORK_MODE, + ATTR_OPTION: NetworkModeEnum.MODE_4G_3G_AUTO.value, + }, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.net.set_net_mode.assert_called_once() + client.return_value.net.set_net_mode.assert_called_with( + LTEBandEnum.ALL, NetworkBandEnum.ALL, NetworkModeEnum.MODE_4G_3G_AUTO.value + ) diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 03c503ae494..3978d0cf175 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -740,7 +740,7 @@ async def test_update_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" state = hass.states.get(input_entity_id) @@ -812,7 +812,7 @@ async def test_ws_create_duplicates( ) resp = await client.receive_json() assert not resp["success"] - assert resp["error"]["code"] == "unknown_error" + assert resp["error"]["code"] == "home_assistant_error" assert resp["error"]["message"] == "Duplicate options are not allowed" assert not hass.states.get(input_entity_id) diff --git a/tests/components/komfovent/__init__.py b/tests/components/komfovent/__init__.py deleted file mode 100644 index e5492a52327..00000000000 --- a/tests/components/komfovent/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Komfovent integration.""" diff --git a/tests/components/komfovent/test_config_flow.py b/tests/components/komfovent/test_config_flow.py deleted file mode 100644 index 008d92e36a3..00000000000 --- a/tests/components/komfovent/test_config_flow.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Test the Komfovent config flow.""" -from unittest.mock import AsyncMock, patch - -import komfovent_api -import pytest - -from homeassistant import config_entries -from homeassistant.components.komfovent.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test flow completes as expected.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_authenticating( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow authentication step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -@pytest.mark.parametrize( - ("error", "expected_response"), - [ - (komfovent_api.KomfoventConnectionResult.NOT_FOUND, "cannot_connect"), - (komfovent_api.KomfoventConnectionResult.UNAUTHORISED, "invalid_auth"), - (komfovent_api.KomfoventConnectionResult.INVALID_INPUT, "invalid_input"), - ], -) -async def test_flow_error_device_info( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - error: komfovent_api.KomfoventConnectionResult, - expected_response: str, -) -> None: - """Test errors during flow device info download step are handled and dont affect final result.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - error, - None, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": expected_response} - - final_result = await __test_normal_flow(hass, result2["flow_id"]) - assert final_result["type"] == FlowResultType.CREATE_ENTRY - assert final_result["title"] == "test-name" - assert final_result["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_device_already_exists( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test device is not added when it already exists.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - unique_id="test-uid", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - final_result = await __test_normal_flow(hass, result["flow_id"]) - assert final_result["type"] == FlowResultType.ABORT - assert final_result["reason"] == "already_configured" - - -async def __test_normal_flow(hass: HomeAssistant, flow_id: str) -> FlowResult: - """Test flow completing as expected, no matter what happened before.""" - - with patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_credentials", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventCredentials("1.1.1.1", "user", "pass"), - ), - ), patch( - "homeassistant.components.komfovent.config_flow.komfovent_api.get_settings", - return_value=( - komfovent_api.KomfoventConnectionResult.SUCCESS, - komfovent_api.KomfoventSettings("test-name", None, None, "test-uid"), - ), - ): - final_result = await hass.config_entries.flow.async_configure( - flow_id, - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - return final_result diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index 814a46f4a25..a83d9fd5e17 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -49,24 +49,20 @@ def mock_plenticore() -> Generator[Plenticore, None, None]: plenticore.client.get_version = AsyncMock() plenticore.client.get_version.return_value = VersionData( - { - "api_version": "0.2.0", - "hostname": "scb", - "name": "PUCK RESTful API", - "sw_version": "01.16.05025", - } + api_version="0.2.0", + hostname="scb", + name="PUCK RESTful API", + sw_version="01.16.05025", ) plenticore.client.get_me = AsyncMock() plenticore.client.get_me.return_value = MeData( - { - "locked": False, - "active": True, - "authenticated": True, - "permissions": [], - "anonymous": False, - "role": "USER", - } + locked=False, + active=True, + authenticated=True, + permissions=[], + anonymous=False, + role="USER", ) plenticore.client.get_process_data = AsyncMock() diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 41facfe9c26..8bfe227bfdf 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -54,7 +54,19 @@ async def test_form_g1( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -108,7 +120,19 @@ async def test_form_g2( # mock of the context manager instance mock_apiclient.login = AsyncMock() mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ), + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index d6a57648400..87c8c0e26a8 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -26,15 +26,13 @@ async def test_entry_diagnostics( mock_plenticore.client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "id": "Battery:MinSoc", - "unit": "%", - "default": "None", - "min": 5, - "max": 100, - "type": "byte", - "access": "readwrite", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ) ] } @@ -56,12 +54,12 @@ async def test_entry_diagnostics( "disabled_by": None, }, "client": { - "version": "Version(api_version=0.2.0, hostname=scb, name=PUCK RESTful API, sw_version=01.16.05025)", - "me": "Me(locked=False, active=True, authenticated=True, permissions=[], anonymous=False, role=USER)", + "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", + "me": "is_locked=False is_active=True is_authenticated=True permissions=[] is_anonymous=False role='USER'", "available_process_data": {"devices:local": ["HomeGrid_P", "HomePv_P"]}, "available_settings_data": { "devices:local": [ - "SettingsData(id=Battery:MinSoc, unit=%, default=None, min=5, max=100,type=byte, access=readwrite)" + "min='5' max='100' default=None access='readwrite' unit='%' id='Battery:MinSoc' type='byte'" ] }, }, diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index 61df222fd9e..93550405897 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from pykoplenti import ApiClient, SettingsData +from pykoplenti import ApiClient, ExtendedApiClient, SettingsData import pytest from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -17,10 +17,10 @@ from tests.common import MockConfigEntry def mock_apiclient() -> Generator[ApiClient, None, None]: """Return a mocked ApiClient class.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as mock_api_class: - apiclient = MagicMock(spec=ApiClient) + apiclient = MagicMock(spec=ExtendedApiClient) apiclient.__aenter__.return_value = apiclient apiclient.__aexit__ = AsyncMock() mock_api_class.return_value = apiclient @@ -34,7 +34,19 @@ async def test_plenticore_async_setup_g1( ) -> None: """Tests the async_setup() method of the Plenticore class for G1 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" @@ -74,7 +86,19 @@ async def test_plenticore_async_setup_g2( ) -> None: """Tests the async_setup() method of the Plenticore class for G2 models.""" mock_apiclient.get_settings = AsyncMock( - return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + return_value={ + "scb:network": [ + SettingsData( + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Network:Hostname", + type="string", + ) + ] + } ) mock_apiclient.get_setting_values = AsyncMock( # G1 model has the entry id "Hostname" diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index dd5ba7127a8..fc7d9f213fe 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -23,9 +23,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture def mock_plenticore_client() -> Generator[ApiClient, None, None]: - """Return a patched ApiClient.""" + """Return a patched ExtendedApiClient.""" with patch( - "homeassistant.components.kostal_plenticore.helper.ApiClient", + "homeassistant.components.kostal_plenticore.helper.ExtendedApiClient", autospec=True, ) as plenticore_client_class: yield plenticore_client_class.return_value @@ -41,39 +41,33 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: mock_plenticore_client.get_settings.return_value = { "devices:local": [ SettingsData( - { - "default": None, - "min": 5, - "max": 100, - "access": "readwrite", - "unit": "%", - "type": "byte", - "id": "Battery:MinSoc", - } + min="5", + max="100", + default=None, + access="readwrite", + unit="%", + id="Battery:MinSoc", + type="byte", ), SettingsData( - { - "default": None, - "min": 50, - "max": 38000, - "access": "readwrite", - "unit": "W", - "type": "byte", - "id": "Battery:MinHomeComsumption", - } + min="50", + max="38000", + default=None, + access="readwrite", + unit="W", + id="Battery:MinHomeComsumption", + type="byte", ), ], "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } @@ -129,15 +123,13 @@ async def test_setup_no_entries( mock_plenticore_client.get_settings.return_value = { "scb:network": [ SettingsData( - { - "min": "1", - "default": None, - "access": "readwrite", - "unit": None, - "id": "Hostname", - "type": "string", - "max": "63", - } + min="1", + max="63", + default=None, + access="readwrite", + unit=None, + id="Hostname", + type="string", ) ], } diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index 682e8f72ac8..9af2589af9b 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -18,8 +18,24 @@ async def test_select_battery_charging_usage_available( mock_plenticore.client.get_settings.return_value = { "devices:local": [ - SettingsData({"id": "Battery:SmartBatteryControl:Enable"}), - SettingsData({"id": "Battery:TimeControl:Enable"}), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:SmartBatteryControl:Enable", + type="string", + ), + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:TimeControl:Enable", + type="string", + ), ] } diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index b58c91f8f16..19338d8d576 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -104,7 +104,7 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -135,7 +135,7 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException port = mock_serial_port() result = await hass.config_entries.flow.async_init( diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 5ed2a397ccd..f05d12e49a2 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -150,7 +150,7 @@ async def test_exception_on_polling( assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException freezer.tick(POLLING_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 675057899b0..962c5500f06 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1444,6 +1444,7 @@ async def test_light_service_call_color_conversion( platform.ENTITIES.append(platform.MockLight("Test_legacy", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_temperature", STATE_ON)) entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {light.ColorMode.HS} @@ -1470,6 +1471,9 @@ async def test_light_service_call_color_conversion( entity6 = platform.ENTITIES[6] entity6.supported_color_modes = {light.ColorMode.RGBWW} + entity7 = platform.ENTITIES[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1498,6 +1502,9 @@ async def test_light_service_call_color_conversion( state = hass.states.get(entity6.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + state = hass.states.get(entity7.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + await hass.services.async_call( "light", "turn_on", @@ -1510,6 +1517,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1530,6 +1538,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( "light", @@ -1543,6 +1553,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1564,6 +1575,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1577,6 +1590,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1597,6 +1611,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( "light", @@ -1610,6 +1626,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1631,6 +1648,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1644,6 +1663,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1664,6 +1684,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( "light", @@ -1677,6 +1699,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1698,6 +1721,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1711,6 +1736,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1732,6 +1758,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( "light", @@ -1745,6 +1773,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1766,6 +1795,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1779,6 +1810,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1799,6 +1831,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( "light", @@ -1812,6 +1846,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1833,6 +1868,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} async def test_light_service_call_color_conversion_named_tuple( diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index b2c79ef4bd1..67d0703ca7c 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -71,7 +71,7 @@ def set_time_zone(hass: HomeAssistant) -> None: ({}, {}), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, ), ({"description": "Additional detail"}, {"description": "Additional detail"}), @@ -118,7 +118,7 @@ async def test_add_item( ({}, {}), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, ), ({"description": "Additional detail"}, {"description": "Additional detail"}), @@ -213,7 +213,7 @@ async def test_bulk_remove( ({"status": "completed"}, {"status": "completed"}, "0"), ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), ( - {"due_date_time": "2023-11-17T11:30:00+00:00"}, + {"due_datetime": "2023-11-17T11:30:00+00:00"}, {"due": "2023-11-17T05:30:00-06:00"}, "1", ), diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 16f40fda786..637acc22d05 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -9,10 +9,6 @@ import pytest from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, - DOMAIN, - SERVICE_LOCK, - SERVICE_OPEN, - SERVICE_UNLOCK, STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, @@ -20,11 +16,8 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, LockEntity, LockEntityFeature, - _async_lock, - _async_open, - _async_unlock, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -87,7 +80,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_locking assert lock.state == STATE_LOCKING - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() assert lock.is_locked assert lock.state == STATE_LOCKED @@ -95,7 +88,7 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert lock.is_unlocking assert lock.state == STATE_UNLOCKING - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() assert not lock.is_locked assert lock.state == STATE_UNLOCKED @@ -189,12 +182,12 @@ async def test_lock_open_with_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "HELLO"})) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "1234"})) + await lock.async_handle_open_service(code="HELLO") + await lock.async_handle_open_service(code="1234") assert lock.calls_open.call_count == 1 @@ -203,16 +196,16 @@ async def test_lock_lock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "HELLO"})) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="HELLO") + await lock.async_handle_lock_service(code="1234") assert lock.is_locked @@ -221,18 +214,16 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: lock = MockLockEntity(code_format=r"^\d{4}$") lock.hass = hass - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_lock_service(code="1234") assert lock.is_locked with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "HELLO"}) - ) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) + await lock.async_handle_unlock_service(code="HELLO") + await lock.async_handle_unlock_service(code="1234") assert not lock.is_locked @@ -245,17 +236,11 @@ async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: lock.hass = hass with pytest.raises(ValueError): - await _async_open( - lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_open_service(code="123456") with pytest.raises(ValueError): - await _async_lock( - lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_lock_service(code="123456") with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"}) - ) + await lock.async_handle_unlock_service(code="123456") async def test_lock_with_no_code(hass: HomeAssistant) -> None: @@ -265,18 +250,18 @@ async def test_lock_with_no_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({}) @@ -292,18 +277,18 @@ async def test_lock_with_default_code(hass: HomeAssistant) -> None: assert lock.state_attributes == {"code_format": r"^\d{4}$"} assert lock._lock_option_default_code == "1234" - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) + await lock.async_handle_open_service(code="") lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) + await lock.async_handle_lock_service(code="") lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) + await lock.async_handle_unlock_service(code="") lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) @@ -316,11 +301,11 @@ async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: ) lock.hass = hass - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"})) + await lock.async_handle_open_service(code="4321") lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_lock_service(code="4321") lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"})) + await lock.async_handle_unlock_service(code="4321") lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) @@ -337,8 +322,8 @@ async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: assert lock._lock_option_default_code == "123456" with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await lock.async_handle_open_service() with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await lock.async_handle_lock_service() with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await lock.async_handle_unlock_service() diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d95b409a67b..671c70168d2 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -5,8 +5,9 @@ from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus import json -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun import freeze_time import pytest import voluptuous as vol @@ -504,10 +505,7 @@ async def test_logbook_describe_event( ) assert await async_setup_component(hass, "logbook", {}) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire("some_event") await async_wait_recording_done(hass) @@ -569,10 +567,7 @@ async def test_exclude_described_event( }, ) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire( "some_automation_event", {logbook.ATTR_NAME: name, logbook.ATTR_ENTITY_ID: entity_id}, diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index e67ab3f841a..4181d73c4d3 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -44,7 +44,7 @@ async def mock_yaml_dashboard(hass): ) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={ "title": "YAML Title", "views": [ diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 05bc7f372b8..a772b37f047 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -141,7 +141,7 @@ async def test_lovelace_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json({"id": 7, "type": "lovelace/config"}) @@ -154,7 +154,7 @@ async def test_lovelace_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json({"id": 8, "type": "lovelace/config", "force": True}) @@ -245,7 +245,7 @@ async def test_dashboard_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json( @@ -260,7 +260,7 @@ async def test_dashboard_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json( diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index f7830f03ed6..4a280eccfda 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -38,7 +38,7 @@ async def test_yaml_resources_backwards( ) -> None: """Test defining resources in YAML ll config (legacy).""" with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"resources": RESOURCE_EXAMPLES}, ): assert await async_setup_component( diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 7a39bc4605d..72e7adb3a13 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -39,7 +39,7 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index a0935154054..d5093367db5 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -71,6 +71,10 @@ async def trigger_subscription_callback( data: Any = None, ) -> None: """Trigger a subscription callback.""" - callback = client.subscribe_events.call_args.kwargs["callback"] - callback(event, data) + # trigger callback on all subscribers + for sub in client.subscribe_events.call_args_list: + callback = sub.kwargs["callback"] + event_filter = sub.kwargs.get("event_filter") + if event_filter in (None, event): + callback(event, data) await hass.async_block_till_done() diff --git a/tests/components/matter/fixtures/config_entry_diagnostics.json b/tests/components/matter/fixtures/config_entry_diagnostics.json index 53477792e43..f591709fbda 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics.json @@ -40,11 +40,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -76,8 +76,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -122,8 +122,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -155,14 +155,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -503,19 +503,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -540,20 +540,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index 8a67ef0fb63..c85ee4d70e3 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -42,11 +42,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -78,8 +78,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -124,8 +124,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -157,14 +157,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -317,19 +317,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -354,20 +354,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color-temperature-light.json index 7552fa833fb..45d1c18635c 100644 --- a/tests/components/matter/fixtures/nodes/color-temperature-light.json +++ b/tests/components/matter/fixtures/nodes/color-temperature-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-color-temperature-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 268, - "revision": 1 + "0": 268, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index 3abecbdf66f..d95fbe5efa9 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -13,8 +13,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -30,11 +30,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -66,8 +66,8 @@ "0/40/17": true, "0/40/18": "869D5F986B588B29", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -112,8 +112,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -143,14 +143,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YFX5V0js", - "IPv4Addresses": ["wKgBIw=="], - "IPv6Addresses": ["/oAAAAAAAABiVfn//ldI7A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX5V0js", + "5": ["wKgBIw=="], + "6": ["/oAAAAAAAABiVfn//ldI7A=="], + "7": 1 } ], "0/51/1": 3, @@ -302,19 +302,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBRgkBwEkCAEwCUEEELwf3lni0ez0mRGa/z9gFtuTfn3Gpnsq/rBvQmpgjxqgC0RNcZmHfAm176H0j6ENQrnc1RhkKA5qiJtEgzQF4DcKNQEoARgkAgE2AwQCBAEYMAQURdGBtNYpheXbKDo2Od5OLDCytacwBRQc+rrVsNzRFL1V9i4OFnGKrwIajRgwC0AG9mdYqL5WJ0jKIBcEzeWQbo8xg6sFv0ANmq0KSpMbfqVvw8Y39XEOQ6B8v+JCXSGMpdPC0nbVQKuv/pKUvJoTGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEWYzjmQq/3zCbWfMKR0asASVnOBOkNAzdwdW1X6sC0zA5m3DhGRMEff09ZqHDZi/o6CW+I+rEGNEyW+00/M84azcKNQEpARgkAmAwBBQc+rrVsNzRFL1V9i4OFnGKrwIajTAFFI6CuLTopCFiBYeGuUcP8Ak5Jo3gGDALQDYMHSAwxZPP4TFqIGot2vm5+Wir58quxbojkWwyT9l8eat6f9sJmjTZ0VLggTwAWvY+IVm82YuMzTPxmkNWxVIY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 5, - "label": "", - "fabricIndex": 1 + "1": "BALNCzn2XOp1NrwszT+LOLYT+tM76+Pob8AIOFl9+0UWFsLp4ZHUainZZMJQIAHxv39srVUYW0+nacFcjHTzNHw=", + "2": 65521, + "3": 1, + "4": 5, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -339,20 +339,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -415,8 +415,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index e14c922857c..7ccc3eef3af 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-dimmable-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json index 6cbd75ab09c..dfa7794f28b 100644 --- a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json +++ b/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json index 1477d78aa67..8a3f0fd68dd 100644 --- a/tests/components/matter/fixtures/nodes/door-lock.json +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -24,11 +24,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -60,8 +60,8 @@ "0/40/17": true, "0/40/18": "mock-door-lock", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -121,8 +121,8 @@ "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 2, @@ -154,28 +154,28 @@ "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth0", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "/mQDt/2Q", - "IPv4Addresses": ["CjwBaQ=="], - "IPv6Addresses": [ + "0": "eth0", + "1": true, + "2": null, + "3": null, + "4": "/mQDt/2Q", + "5": ["CjwBaQ=="], + "6": [ "/VqgxiAxQib8ZAP//rf9kA==", "IAEEcLs7AAb8ZAP//rf9kA==", "/oAAAAAAAAD8ZAP//rf9kA==" ], - "type": 2 + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 1, @@ -195,39 +195,39 @@ ], "0/52/0": [ { - "id": 26957, - "name": "26957", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26957, + "1": "26957", + "2": null, + "3": null, + "4": null }, { - "id": 26956, - "name": "26956", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26956, + "1": "26956", + "2": null, + "3": null, + "4": null }, { - "id": 26955, - "name": "26955", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26955, + "1": "26955", + "2": null, + "3": null, + "4": null }, { - "id": 26953, - "name": "26953", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26953, + "1": "26953", + "2": null, + "3": null, + "4": null }, { - "id": 26952, - "name": "26952", - "stackFreeCurrent": null, - "stackFreeMinimum": null, - "stackSize": null + "0": 26952, + "1": "26952", + "2": null, + "3": null, + "4": null } ], "0/52/1": 351120, @@ -358,19 +358,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", - "fabricIndex": 1 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 16, @@ -395,20 +395,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -443,8 +443,8 @@ ], "1/29/0": [ { - "deviceType": 10, - "revision": 1 + "0": 10, + "1": 1 } ], "1/29/1": [3, 6, 29, 47, 257], diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json index b0eacfb621c..a009796f940 100644 --- a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json @@ -12,16 +12,16 @@ "0/53/47": 0, "0/53/8": [ { - "extAddress": 12872547289273451492, - "rloc16": 1024, - "routerId": 1, - "nextHop": 0, - "pathCost": 0, - "LQIIn": 3, - "LQIOut": 3, - "age": 142, - "allocated": true, - "linkEstablished": true + "0": 12872547289273451492, + "1": 1024, + "2": 1, + "3": 0, + "4": 0, + "5": 3, + "6": 3, + "7": 142, + "8": true, + "9": true } ], "0/53/29": 1556, @@ -30,20 +30,20 @@ "0/53/40": 519, "0/53/7": [ { - "extAddress": 12872547289273451492, - "age": 654, - "rloc16": 1024, - "linkFrameCounter": 738, - "mleFrameCounter": 418, - "lqi": 3, - "averageRssi": -50, - "lastRssi": -51, - "frameErrorRate": 5, - "messageErrorRate": 0, - "rxOnWhenIdle": true, - "fullThreadDevice": true, - "fullNetworkData": true, - "isChild": false + "0": 12872547289273451492, + "1": 654, + "2": 1024, + "3": 738, + "4": 418, + "5": 3, + "6": -50, + "7": -51, + "8": 5, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false } ], "0/53/33": 66, @@ -124,9 +124,9 @@ "0/53/16": 0, "0/42/0": [ { - "providerNodeID": 1773685588, - "endpoint": 0, - "fabricIndex": 1 + "1": 1773685588, + "2": 0, + "254": 1 } ], "0/42/65528": [], @@ -140,8 +140,8 @@ "0/48/65532": 0, "0/48/65528": [1, 3, 5], "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], @@ -158,25 +158,25 @@ "0/31/1": [], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/65532": 0, @@ -187,8 +187,8 @@ "0/49/65533": 1, "0/49/1": [ { - "networkID": "Uv50lWMtT7s=", - "connected": true + "0": "Uv50lWMtT7s=", + "1": true } ], "0/49/3": 20, @@ -217,8 +217,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 46, 48, 49, 51, 53, 60, 62, 63], @@ -226,18 +226,18 @@ "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "ieee802154", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "YtmXHFJ/dhk=", - "IPv4Addresses": [], - "IPv6Addresses": [ + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "YtmXHFJ/dhk=", + "5": [], + "6": [ "/RG+U41GAABynlpPU50e5g==", "/oAAAAAAAABg2ZccUn92GQ==", "/VL+dJVjAAB1cwmi02rvTA==" ], - "type": 4 + "7": 4 } ], "0/51/65529": [0], @@ -261,8 +261,8 @@ "0/40/6": "**REDACTED**", "0/40/3": "Eve Door", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/2": 4874, "0/40/65532": 0, @@ -302,8 +302,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 21, - "revision": 1 + "0": 21, + "1": 1 } ], "1/29/65528": [], diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug.json b/tests/components/matter/fixtures/nodes/eve-energy-plug.json new file mode 100644 index 00000000000..03ff4ce7dba --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug.json @@ -0,0 +1,649 @@ +{ + "node_id": 83, + "date_commissioned": "2023-11-30T14:39:37.020026", + "last_interview": "2023-11-30T14:39:37.020029", + "interview_version": 5, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Energy Plug", + "0/40/4": 80, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 6650, + "0/40/10": "3.2.1", + "0/40/15": "RV44L1A00081", + "0/40/18": "26E8F90561D17C42", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [ + { + "1": 2312386028615903905, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "cfUKbvsdfsBjT+0=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "cfUKbvBjdsffwT+0=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ieee802154", + "1": true, + "2": null, + "3": null, + "4": "ymtKI/b4u+4=", + "5": [], + "6": [ + "/oAAAAA13414AAADIa0oj9vi77g==", + "/XH1Cm71434wAAB8TZpoASmxuw==", + "/RtUBAb134134mAAAPypryIKqshA==" + ], + "7": 4 + } + ], + "0/51/1": 95, + "0/51/2": 268574, + "0/51/3": 4406, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome23", + "0/53/3": 14707, + "0/53/4": 8211480967175688173, + "0/53/5": "QP1x9Qfwefu8AAA", + "0/53/6": 0, + "0/53/7": [ + { + "0": 13418684826835773064, + "1": 9, + "2": 3072, + "3": 56455, + "4": 84272, + "5": 1, + "6": -89, + "7": -88, + "8": 16, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3054316089463545304, + "1": 2, + "2": 12288, + "3": 17170, + "4": 58113, + "5": 3, + "6": -45, + "7": -46, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 3650476115380598997, + "1": 13, + "2": 15360, + "3": 172475, + "4": 65759, + "5": 3, + "6": -17, + "7": -18, + "8": 12, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 11968039652259981925, + "1": 21, + "2": 21504, + "3": 127929, + "4": 55363, + "5": 3, + "6": -74, + "7": -72, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17156405262946673420, + "1": 22, + "2": 22528, + "3": 22063, + "4": 137698, + "5": 1, + "6": -92, + "7": -92, + "8": 34, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 17782243871947087975, + "1": 18, + "2": 23552, + "3": 157044, + "4": 122272, + "5": 2, + "6": -81, + "7": -82, + "8": 3, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 8276316979900166010, + "1": 17, + "2": 31744, + "3": 486113, + "4": 298427, + "5": 2, + "6": -83, + "7": -82, + "8": 0, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + }, + { + "0": 9121696247933828996, + "1": 48, + "2": 53248, + "3": 651530, + "4": 161559, + "5": 3, + "6": -70, + "7": -71, + "8": 15, + "9": 0, + "10": true, + "11": true, + "12": true, + "13": false + } + ], + "0/53/8": [ + { + "0": 13418684826835773064, + "1": 3072, + "2": 3, + "3": 15, + "4": 1, + "5": 1, + "6": 1, + "7": 9, + "8": true, + "9": true + }, + { + "0": 0, + "1": 7168, + "2": 7, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 76, + "8": true, + "9": false + }, + { + "0": 0, + "1": 10240, + "2": 10, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 243, + "8": true, + "9": false + }, + { + "0": 3054316089463545304, + "1": 12288, + "2": 12, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 2, + "8": true, + "9": true + }, + { + "0": 3650476115380598997, + "1": 15360, + "2": 15, + "3": 12, + "4": 1, + "5": 3, + "6": 3, + "7": 14, + "8": true, + "9": true + }, + { + "0": 11968039652259981925, + "1": 21504, + "2": 21, + "3": 15, + "4": 1, + "5": 3, + "6": 2, + "7": 22, + "8": true, + "9": true + }, + { + "0": 17156405262946673420, + "1": 22528, + "2": 22, + "3": 52, + "4": 1, + "5": 1, + "6": 0, + "7": 23, + "8": true, + "9": true + }, + { + "0": 17782243871947087975, + "1": 23552, + "2": 23, + "3": 15, + "4": 1, + "5": 2, + "6": 2, + "7": 19, + "8": true, + "9": true + }, + { + "0": 0, + "1": 29696, + "2": 29, + "3": 21, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 8276316979900166010, + "1": 31744, + "2": 31, + "3": 52, + "4": 1, + "5": 2, + "6": 2, + "7": 18, + "8": true, + "9": true + }, + { + "0": 0, + "1": 39936, + "2": 39, + "3": 52, + "4": 1, + "5": 0, + "6": 0, + "7": 31, + "8": true, + "9": false + }, + { + "0": 9121696247933828996, + "1": 53248, + "2": 52, + "3": 15, + "4": 1, + "5": 3, + "6": 3, + "7": 48, + "8": true, + "9": true + }, + { + "0": 14585833336497290222, + "1": 54272, + "2": 53, + "3": 63, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": true, + "9": false + } + ], + "0/53/9": 1828774034, + "0/53/10": 68, + "0/53/11": 237, + "0/53/12": 170, + "0/53/13": 23, + "0/53/14": 2, + "0/53/15": 1, + "0/53/16": 2, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 2, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 293884, + "0/53/23": 278934, + "0/53/24": 14950, + "0/53/25": 278894, + "0/53/26": 278468, + "0/53/27": 14990, + "0/53/28": 293844, + "0/53/29": 0, + "0/53/30": 40, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 65244, + "0/53/34": 426, + "0/53/35": 0, + "0/53/36": 87, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 6687540, + "0/53/40": 142626, + "0/53/41": 106835, + "0/53/42": 246171, + "0/53/43": 0, + "0/53/44": 541, + "0/53/45": 40, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6360718, + "0/53/49": 2141, + "0/53/50": 35259, + "0/53/51": 4374, + "0/53/52": 0, + "0/53/53": 568, + "0/53/54": 18599, + "0/53/55": 19143, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRUxgkBwEkCAEwCUEEg58CF25hrI1R598dXwRapPCYUjahad5XkJMrA0tZb8HXO67XlyD4L+1ljtb6IAHhxjOGew2jNVSQDH1aqRGsODcKNQEoARgkAgE2AwQCBAEYMAQUkpBmmh0G57MnnxYDgxZuAZBezjYwBRTphWiJ/NqGe3Cx3Nj8H02NgGioSRgwC0CCOOCnKlhpegJmaH8vSIO38MQcJq+qV85UPPqaYc8dakaAnASvYeurP41Jw4KrCqyLMNRhUwqeyKoql6iQFKNAGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEYztrLK2UY1ORHUEFLO7PDfVjw/MnMDNX5kjdHHDU7npeITnSyg/kxxUM+pD7ccxfDuHQKHbBq9+qbJi8oGik8DcKNQEpARgkAmAwBBTphWiJ/NqGe3Cx3Nj8H02NgGioSTAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQOOcZAL8XEktvE5sjrUmFNhkP2g3Ef+4BHtogItdZYyA9E/WbzW25E0UxZInwjjIzH3YimDUZVoEWGML8NV2kCEY", + "254": 5 + } + ], + "0/62/1": [ + { + "1": "BIbR4Iu8CNIdxKRkSjTb1LKY3nzCbFVwDrjkRe4WDorCiMZHJmypZW24wBgAHxNo8D00QWw29llu8FH1eOtmHIo=", + "2": 4937, + "3": 1, + "4": 3878431683, + "5": "Thuis", + "254": 1 + }, + { + "1": "BLlk4ui4wSQ+xz89jB5nBRQUVYdY9H2dBUawGXVUxa2bsKh2k8CHijv1tkz1dThPXA9UK8jOAZ+7Mi+y7BPuAcg=", + "2": 4996, + "3": 2, + "4": 3763070728, + "5": "", + "254": 2 + }, + { + "1": "BAg5aeR7RuFKZhukCxMGglCd00dKlhxGq8BbjeyZClKz5kN2Ytzav0xWsiWEEb3s9uvMIYFoQYULnSJvOMTcD14=", + "2": 65521, + "3": 1, + "4": 83, + "5": "", + "254": 5 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUxofpv3kE1HwkFQEYJgS2Ty8rJgU2gxAtNwYnFMaH6b95BNR8JBUBGCQHASQIATAJQQSG0eCLvAjSHcSkZEo029SymN58wmxVcA645EXuFg6KwojGRyZsqWVtuMAYAB8TaPA9NEFsNvZZbvBR9XjrZhyKNwo1ASkBGCQCYDAEFNnFRJ+9qQIJtsM+LRdMdmCY3bQ4MAUU2cVEn72pAgm2wz4tF0x2YJjdtDgYMAtAFDv6Ouh7ugAGLiCjBQaEXCIAe0AkaaN8dBPskCZXOODjuZ1DCr4/f5IYg0rN2zFDUDTvG3GCxoI1+A7BvSjiNRg=", + "FTABAQAkAgE3AycUjuqR8vTQCmEkFQIYJgTFTy8rJgVFgxAtNwYnFI7qkfL00AphJBUCGCQHASQIATAJQQS5ZOLouMEkPsc/PYweZwUUFFWHWPR9nQVGsBl1VMWtm7CodpPAh4o79bZM9XU4T1wPVCvIzgGfuzIvsuwT7gHINwo1ASkBGCQCYDAEFKEEplpzAvCzsc5ga6CFmqmsv5onMAUUoQSmWnMC8LOxzmBroIWaqay/micYMAtAYkkA8OZFIGpxBEYYT+3A7Okba4WOq4NtwctIIZvCM48VU8pxQNjVvHMcJWPOP1Wh2Bw1VH7/Sg9lt9DL4DAwjBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEECDlp5HtG4UpmG6QLEwaCUJ3TR0qWHEarwFuN7JkKUrPmQ3Zi3Nq/TFayJYQRvez268whgWhBhQudIm84xNwPXjcKNQEpARgkAmAwBBTJ3+WZAQkWgZboUpiyZL3FV8R8UzAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQO9QSAdvJkM6b/wIc07MCw1ma46lTyGYG8nvpn0ICI73nuD3QeaWwGIQTkVGEpzF+TuDK7gtTz7YUrR+PSnvMk8Y" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 29, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFQCwIAAAMC+xkEDFJWNDRMMUEwMDA4MZwBAP8EAQIA1PkBAWABZNAEAAAAAEUFBQAAAABGCQUAAAAOAABCBkkGBQwIEIABRBEFFAAFAzwAAAAAAAAAAAAAAEcRBSoh/CGWImgjeAAAADwAAABIBgUAAAAAAEoGBQAAAAAA", + "1/319486977/319422466": "BEZiAQAAAAAAAAAABgsCDAINAgcCDgEBAn4PABAAWgAAs8c+AQEA", + "1/319486977/319422467": "EgtaAAB74T4BDwAANwkAAAAA", + "1/319486977/319422471": 0, + "1/319486977/319422472": 238.8000030517578, + "1/319486977/319422473": 0.0, + "1/319486977/319422474": 0.0, + "1/319486977/319422475": 0.2200000286102295, + "1/319486977/319422476": 0, + "1/319486977/319422478": 0, + "1/319486977/319422481": false, + "1/319486977/319422482": 54272, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, + 319422475, 319422476, 319422478, 319422481, 319422482, 65533 + ] + }, + "attribute_subscriptions": [], + "last_subscription_attempt": 0 +} diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended-color-light.json index f4d83239b6d..d18b76768ca 100644 --- a/tests/components/matter/fixtures/nodes/extended-color-light.json +++ b/tests/components/matter/fixtures/nodes/extended-color-light.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], @@ -20,11 +20,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 52 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 52 } ], "0/31/1": [], @@ -50,8 +50,8 @@ "0/40/17": true, "0/40/18": "mock-extended-color-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 65535 + "0": 3, + "1": 65535 }, "0/40/65532": 0, "0/40/65533": 1, @@ -63,8 +63,8 @@ ], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 2, "0/48/3": 2, @@ -77,8 +77,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "ZXRoMA==", - "connected": true + "0": "ZXRoMA==", + "1": true } ], "0/49/4": true, @@ -92,38 +92,38 @@ "0/49/65531": [0, 1, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "eth1", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "ABeILIy4", - "IPv4Addresses": ["CjwBuw=="], - "IPv6Addresses": [ + "0": "eth1", + "1": true, + "2": null, + "3": null, + "4": "ABeILIy4", + "5": ["CjwBuw=="], + "6": [ "/VqgxiAxQiYCF4j//iyMuA==", "IAEEcLs7AAYCF4j//iyMuA==", "/oAAAAAAAAACF4j//iyMuA==" ], - "type": 0 + "7": 0 }, { - "name": "eth0", - "isOperational": false, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAN/ESDO", - "IPv4Addresses": [], - "IPv6Addresses": [], - "type": 2 + "0": "eth0", + "1": false, + "2": null, + "3": null, + "4": "AAN/ESDO", + "5": [], + "6": [], + "7": 2 }, { - "name": "lo", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "AAAAAAAA", - "IPv4Addresses": ["fwAAAQ=="], - "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], - "type": 0 + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 } ], "0/51/1": 4, @@ -151,19 +151,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", - "fabricIndex": 52 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEEYGMAhVV+Adasucgyi++1D7eyBIfHs9xLKJPVJqJdMAqt0S8lQs+6v/NAyAVXsN8jdGlNgZQENRnfqC2gXv3COzcKNQEoARgkAgE2AwQCBAEYMAQUTK/GvAzp9yCT0ihFRaEyW8KuO0IwBRQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtBgwC0CaO1hqAR9PQJUkSx4MQyHEDQND/3j7m6EPRImPCA53dKI7e4w7xZEQEW95oMhuUobdy3WbMcggAMTX46ninwqUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEqboEMvSYpJHvrznp5AQ1fHW0AVUrTajBHZ/2uba7+FTyPb+fqgf6K1zbuMqTxTOA/FwjzAL7hQTwG+HNnmLwNTcKNQEpARgkAmAwBBQ5RmCO0h/Cd/uv6Pe62ZSLBzXOtDAFFG02YRl97W++GsAiEiBzIhO0hzA6GDALQBl+ZyFbSXu3oXVJGBjtDcpwOCRC30OaVjDhUT7NbohDLaKuwxMhAgE+uHtSLKRZPGlQGSzYdnDGj/dWolGE+n4Y", + "254": 52 } ], "0/62/1": [ { - "rootPublicKey": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 52 + "1": "BOI8+YJvCUh78+5WD4aHD7t1HQJS3WMrCEknk6n+5HXP2VRMB3SvK6+EEa8rR6UkHnCryIREeOmS0XYozzHjTQg=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 52 } ], "0/62/2": 16, @@ -202,8 +202,8 @@ ], "1/29/0": [ { - "deviceType": 269, - "revision": 1 + "0": 269, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 80, 3, 4], @@ -277,19 +277,19 @@ "1/80/1": 0, "1/80/2": [ { - "label": "Dark", - "mode": 0, - "semanticTags": [] + "0": "Dark", + "1": 0, + "2": [] }, { - "label": "Medium", - "mode": 1, - "semanticTags": [] + "0": "Medium", + "1": 1, + "2": [] }, { - "label": "Light", - "mode": 2, - "semanticTags": [] + "0": "Light", + "1": 2, + "2": [] } ], "1/80/3": 0, diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow-sensor.json index e1fc2a36585..a8dad202fa1 100644 --- a/tests/components/matter/fixtures/nodes/flow-sensor.json +++ b/tests/components/matter/fixtures/nodes/flow-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-flow-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 774, - "revision": 1 + "0": 774, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json index 15c93825307..f564e91a1ce 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch-multi.json +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], @@ -77,17 +77,16 @@ "1/59/65528": [], "1/64/0": [ { - "label": "Label", - "value": "1" + "0": "Label", + "1": "1" } ], - "2/3/65529": [0, 64], "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "2/29/1": [3, 29, 59], @@ -107,8 +106,8 @@ "2/59/65528": [], "2/64/0": [ { - "label": "Label", - "value": "Fancy Button" + "0": "Label", + "1": "Fancy Button" } ] }, diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic-switch.json index 30763c88e5b..80773915748 100644 --- a/tests/components/matter/fixtures/nodes/generic-switch.json +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-generic-switch", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 15, - "revision": 1 + "0": 15, + "1": 1 } ], "1/29/1": [3, 29, 59], diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity-sensor.json index a1940fc1857..8220c9cf8f8 100644 --- a/tests/components/matter/fixtures/nodes/humidity-sensor.json +++ b/tests/components/matter/fixtures/nodes/humidity-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-humidity-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 775, - "revision": 1 + "0": 775, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light-sensor.json index 93583c34292..c4d84bc7923 100644 --- a/tests/components/matter/fixtures/nodes/light-sensor.json +++ b/tests/components/matter/fixtures/nodes/light-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-light-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 262, - "revision": 1 + "0": 262, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy-sensor.json index d8f2580c2b0..f63dd43362b 100644 --- a/tests/components/matter/fixtures/nodes/occupancy-sensor.json +++ b/tests/components/matter/fixtures/nodes/occupancy-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 263, - "revision": 1 + "0": 263, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json index 43ba486bc29..8d523f5443a 100644 --- a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json +++ b/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-plugin-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -118,8 +118,8 @@ ], "1/29/0": [ { - "deviceType": 266, - "revision": 1 + "0": 266, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json index f29361da128..3f6e83ca460 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json index 8a1134409a9..18cb68c8926 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff-light.json index 65ef0be5c8e..eed404ff85d 100644 --- a/tests/components/matter/fixtures/nodes/onoff-light.json +++ b/tests/components/matter/fixtures/nodes/onoff-light.json @@ -12,8 +12,8 @@ "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 1 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "mock-onoff-light", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -111,8 +111,8 @@ "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -125,8 +125,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "", - "connected": true + "0": "", + "1": true } ], "0/49/2": 10, @@ -147,14 +147,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "", - "IPv4Addresses": [""], - "IPv6Addresses": [], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "", + "5": [""], + "6": [], + "7": 1 } ], "0/51/1": 6, @@ -243,19 +243,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": "", - "fabricIndex": 1 + "1": "", + "2": "", + "254": 1 } ], "0/62/1": [ { - "rootPublicKey": "", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 1 + "1": "", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 1 } ], "0/62/2": 5, @@ -278,20 +278,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -354,8 +354,8 @@ ], "1/29/0": [ { - "deviceType": 257, - "revision": 1 + "0": 257, + "1": 1 } ], "1/29/1": [3, 4, 6, 8, 29, 768, 1030], diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure-sensor.json index a47cda28056..d38ac560ac5 100644 --- a/tests/components/matter/fixtures/nodes/pressure-sensor.json +++ b/tests/components/matter/fixtures/nodes/pressure-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-pressure-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -56,8 +56,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 773, - "revision": 1 + "0": 773, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch-unit.json index ceed22d2524..e16f1e406ec 100644 --- a/tests/components/matter/fixtures/nodes/switch-unit.json +++ b/tests/components/matter/fixtures/nodes/switch-unit.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 99999, - "revision": 1 + "0": 99999, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-switch-unit", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -96,8 +96,8 @@ "1/7/65531": [0, 16, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 9999999, - "revision": 1 + "0": 9999999, + "1": 1 } ], "1/29/1": [ diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature-sensor.json index c7d372ac2d7..0abb366f81b 100644 --- a/tests/components/matter/fixtures/nodes/temperature-sensor.json +++ b/tests/components/matter/fixtures/nodes/temperature-sensor.json @@ -6,8 +6,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -41,8 +41,8 @@ "0/40/17": true, "0/40/18": "mock-temperature-sensor", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -61,8 +61,8 @@ "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 770, - "revision": 1 + "0": 770, + "1": 1 } ], "1/29/1": [6, 29, 57, 768, 8, 40], diff --git a/tests/components/matter/fixtures/nodes/thermostat.json b/tests/components/matter/fixtures/nodes/thermostat.json index 85ac42e5429..a7abff41331 100644 --- a/tests/components/matter/fixtures/nodes/thermostat.json +++ b/tests/components/matter/fixtures/nodes/thermostat.json @@ -8,8 +8,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 42, 48, 49, 50, 51, 54, 60, 62, 63, 64], @@ -22,18 +22,18 @@ "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -64,8 +64,8 @@ "0/40/17": true, "0/40/18": "3D06D025F9E026A0", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -86,8 +86,8 @@ "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -100,8 +100,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -122,18 +122,18 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "3FR1X7qs", - "IPv4Addresses": ["wKgI7g=="], - "IPv6Addresses": [ + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "3FR1X7qs", + "5": ["wKgI7g=="], + "6": [ "/oAAAAAAAADeVHX//l+6rA==", "JA4DsgZ9jUDeVHX//l+6rA==", "/UgvJAe/AADeVHX//l+6rA==" ], - "type": 1 + "7": 1 } ], "0/51/1": 4, @@ -182,32 +182,32 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRBBgkBwEkCAEwCUEETaqdhs6MRkbh8fdh4EEImZaziiE6anaVp6Mu3P/zIJUB0fHUMxydKRTAC8bIn7vUhBCM47OYlYTkX0zFhoKYrzcKNQEoARgkAgE2AwQCBAEYMAQUrouBLuksQTkLrFhNVAbTHkNvMSEwBRTPlgMACvPdpqPOzuvR0OfPgfUcxBgwC0AcUInETXp/2gIFGDQF2+u+9WtYtvIfo6C3MhoOIV1SrRBZWYxY3CVjPGK7edTibQrVA4GccZKnHhNSBjxktrPiGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE+rI5XQyifTZbZRK1Z2DOuXdQkmdUkWklTv+G1x4ZfbSupbUDo4l7i/iFdyu//uJThAw1GPEkWe6i98IFKCOQpzcKNQEpARgkAmAwBBTPlgMACvPdpqPOzuvR0OfPgfUcxDAFFJQo6UEBWTLtZVYFZwRBgn+qstpTGDALQK3jYiaxwnYJMwTBQlcVNrGxPtuVTZrp5foZtQCp/JEX2ZWqVxKypilx0ES/CfMHZ0Lllv9QsLs8xV/HNLidllkY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", - "vendorID": 4996, - "fabricID": 1, - "nodeID": 1425709672, - "label": "", - "fabricIndex": 1 + "1": "BAP9BJt5aQ9N98ClPTdNxpMZ1/Vh8r9usw6C8Ygi79AImsJq4UjAaYad0UI9Lh0OmRA9sWE2aSPbHjf409i/970=", + "2": 4996, + "3": 1, + "4": 1425709672, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", - "vendorID": 65521, - "fabricID": 1, - "nodeID": 4, - "label": "", - "fabricIndex": 2 + "1": "BJXfyipMp+Jx4pkoTnvYoAYODis4xJktKdQXu8MSpBLIwII58BD0KkIG9NmuHcp0xUQKzqlfyB/bkAanevO73ZI=", + "2": 65521, + "3": 1, + "4": 4, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -233,20 +233,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -275,8 +275,8 @@ "1/6/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 769, - "revision": 1 + "0": 769, + "1": 1 } ], "1/29/1": [3, 4, 6, 29, 30, 64, 513, 514, 516], @@ -295,20 +295,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json index feb75409526..fc6efe2077c 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_full.json +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-full-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json index afc2a2f734f..9c58869e988 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-lift-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json index 8d3335bbd6c..fe970b6ed6b 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json @@ -7,8 +7,8 @@ "attributes": { "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [ @@ -29,11 +29,11 @@ "0/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/31/0": [ { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 2 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 } ], "0/31/1": [], @@ -65,8 +65,8 @@ "0/40/17": true, "0/40/18": "7630EF9998EDF03C", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65532": 0, "0/40/65533": 1, @@ -117,8 +117,8 @@ "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], "0/48/0": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/2": 0, "0/48/3": 0, @@ -131,8 +131,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "TE9OR0FOLUlPVA==", - "connected": true + "0": "TE9OR0FOLUlPVA==", + "1": true } ], "0/49/2": 10, @@ -153,17 +153,14 @@ "0/50/65531": [65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "hPcDB5/k", - "IPv4Addresses": ["wKgIhg=="], - "IPv6Addresses": [ - "/oAAAAAAAACG9wP//gef5A==", - "JA4DsgZ+bsCG9wP//gef5A==" - ], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "hPcDB5/k", + "5": ["wKgIhg=="], + "6": ["/oAAAAAAAACG9wP//gef5A==", "JA4DsgZ+bsCG9wP//gef5A=="], + "7": 1 } ], "0/51/1": 35, @@ -201,19 +198,19 @@ "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], "0/62/0": [ { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", - "fabricIndex": 2 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE5Rw88GvXEUXr+cPYgKd00rIWyiHM8eu4Bhrzf1v83yBI2Qa+pwfOsKyvzxiuHLMfzhdC3gre4najpimi8AsX+TcKNQEoARgkAgE2AwQCBAEYMAQUWh6NlHAMbG5gz+vqlF51fulr3z8wBRR+D1hE33RhFC/mJWrhhZs6SVStQBgwC0DD5IxVgOrftUA47K1bQHaCNuWqIxf/8oMfcI0nMvTtXApwbBAJI/LjjCwMZJVFBE3W/FC6dQWSEuF8ES745tLBGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEzpstYxy3lXF69g6H2vQ6uoqkdUsppJ4NcSyQcXQ8sQrF5HuzoVnDpevHfy0GAWHbXfE4VI0laTHvm/Wkj037ZjcKNQEpARgkAmAwBBR+D1hE33RhFC/mJWrhhZs6SVStQDAFFFCCK5NYv6CrD5/0S26zXBUwG0WBGDALQI5YKo3C3xvdqCrho2yZIJVJpJY2n9V/tmh7ESBBOHrY0b+K8Pf7hKhd5V0vzbCCbkhv1BNEne+lhcS2N6qhMNgY", + "254": 2 } ], "0/62/1": [ { - "rootPublicKey": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", - "vendorId": 65521, - "fabricId": 1, - "nodeId": 1, - "label": "", - "fabricIndex": 2 + "1": "BFLMrM1satBpU0DN4sri/S4AVo/ugmZCndBfPO33Q+ZCKDZzNhMOB014+hZs0KL7vPssavT7Tb9nt0W+kpeAe0U=", + "2": 65521, + "3": 1, + "4": 1, + "5": "", + "254": 2 } ], "0/62/2": 5, @@ -239,20 +236,20 @@ "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], "0/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "0/64/65532": 0, @@ -281,8 +278,8 @@ "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 514, - "revision": 1 + "0": 514, + "1": 1 } ], "1/29/1": [3, 4, 29, 30, 64, 65, 258], @@ -301,20 +298,20 @@ "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], "1/64/0": [ { - "label": "room", - "value": "bedroom 2" + "0": "room", + "1": "bedroom 2" }, { - "label": "orientation", - "value": "North" + "0": "orientation", + "1": "North" }, { - "label": "floor", - "value": "2" + "0": "floor", + "1": "2" }, { - "label": "direction", - "value": "up" + "0": "direction", + "1": "up" } ], "1/64/65532": 0, diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json index 44347dbd964..92a1d820d2e 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock_pa_tilt_window_covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json index a33e0f24c3f..144348b5c76 100644 --- a/tests/components/matter/fixtures/nodes/window-covering_tilt.json +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -8,8 +8,8 @@ "0/29/65533": 1, "0/29/0": [ { - "deviceType": 22, - "revision": 1 + "0": 22, + "1": 1 } ], "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], @@ -22,25 +22,25 @@ "0/31/65533": 1, "0/31/0": [ { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 1 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 1 }, { - "privilege": 0, - "authMode": 0, - "subjects": null, - "targets": null, - "fabricIndex": 2 + "1": 0, + "2": 0, + "3": null, + "4": null, + "254": 2 }, { - "privilege": 5, - "authMode": 2, - "subjects": [112233], - "targets": null, - "fabricIndex": 3 + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 } ], "0/31/2": 4, @@ -71,8 +71,8 @@ "0/40/17": true, "0/40/18": "mock-tilt-window-covering", "0/40/19": { - "caseSessionsPerFabric": 3, - "subscriptionsPerFabric": 3 + "0": 3, + "1": 3 }, "0/40/65533": 1, "0/40/65528": [], @@ -84,8 +84,8 @@ "0/48/2": 0, "0/48/3": 0, "0/48/1": { - "failSafeExpiryLengthSeconds": 60, - "maxCumulativeFailsafeSeconds": 900 + "0": 60, + "1": 900 }, "0/48/4": true, "0/48/65533": 1, @@ -96,8 +96,8 @@ "0/49/0": 1, "0/49/1": [ { - "networkID": "MTI2MDk5", - "connected": true + "0": "MTI2MDk5", + "1": true } ], "0/49/2": 10, @@ -113,14 +113,14 @@ "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], "0/51/0": [ { - "name": "WIFI_STA_DEF", - "isOperational": true, - "offPremiseServicesReachableIPv4": null, - "offPremiseServicesReachableIPv6": null, - "hardwareAddress": "JG8olrDo", - "IPv4Addresses": ["wKgBFw=="], - "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], - "type": 1 + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "JG8olrDo", + "5": ["wKgBFw=="], + "6": ["/oAAAAAAAAAmbyj//paw6A=="], + "7": 1 } ], "0/51/1": 1, @@ -141,47 +141,47 @@ "0/62/65532": 0, "0/62/0": [ { - "noc": "", - "icac": null, - "fabricIndex": 1 + "1": "", + "2": null, + "254": 1 }, { - "noc": "", - "icac": null, - "fabricIndex": 2 + "1": "", + "2": null, + "254": 2 }, { - "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", - "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", - "fabricIndex": 3 + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "254": 3 } ], "0/62/2": 5, "0/62/3": 3, "0/62/1": [ { - "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", - "vendorId": 24582, - "fabricId": 7331465149450221740, - "nodeId": 3429688654, - "label": "", - "fabricIndex": 1 + "1": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "2": 24582, + "3": 7331465149450221740, + "4": 3429688654, + "5": "", + "254": 1 }, { - "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", - "vendorId": 4362, - "fabricId": 8516517930550670493, - "nodeId": 1443093566726981311, - "label": "", - "fabricIndex": 2 + "1": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "2": 4362, + "3": 8516517930550670493, + "4": 1443093566726981311, + "5": "", + "254": 2 }, { - "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", - "vendorId": 4939, - "fabricId": 2, - "nodeId": 50, - "label": "", - "fabricIndex": 3 + "1": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "2": 4939, + "3": 2, + "4": 50, + "5": "", + "254": 3 } ], "0/62/4": [ @@ -216,8 +216,8 @@ "1/29/65533": 1, "1/29/0": [ { - "deviceType": 514, - "revision": 2 + "0": 514, + "1": 2 } ], "1/29/1": [29, 3, 258], diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8ed309f61df..35e6673114e 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -145,9 +145,12 @@ async def test_node_added_subscription( ) -> None: """Test subscription to new devices work.""" assert matter_client.subscribe_events.call_count == 4 - assert matter_client.subscribe_events.call_args[0][1] == EventType.NODE_ADDED + assert ( + matter_client.subscribe_events.call_args.kwargs["event_filter"] + == EventType.NODE_ADDED + ) - node_added_callback = matter_client.subscribe_events.call_args[0][0] + node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] node_data = load_and_parse_node_fixture("onoff-light") node = MatterNode( dataclass_from_dict( diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index a9753824edc..991d23f3353 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,6 +14,7 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er from .common import set_node_attribute, trigger_subscription_callback @@ -101,6 +102,7 @@ async def test_lock_requires_pin( hass: HomeAssistant, matter_client: MagicMock, door_lock: MatterNode, + entity_registry: er.EntityRegistry, ) -> None: """Test door lock with PINCode.""" @@ -137,6 +139,26 @@ async def test_lock_requires_pin( timed_request_timeout_ms=1000, ) + # Lock door using default code + default_code = "7654321" + entity_registry.async_update_entity_options( + "lock.mock_door_lock", "lock", {"default_code": default_code} + ) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "lock", + "lock", + {"entity_id": "lock.mock_door_lock"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(default_code.encode()), + timed_request_timeout_ms=1000, + ) + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index c7a0ed0d8a3..61988a37122 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -60,16 +60,13 @@ async def test_get_node_from_device_entry( assert node_from_device_entry is node - with pytest.raises(ValueError) as value_error: - await get_node_from_device_entry(hass, other_device_entry) - - assert f"Device {other_device_entry.id} is not a Matter device" in str( - value_error.value - ) + # test non-Matter device returns None + assert get_node_from_device_entry(hass, other_device_entry) is None matter_client.server_info = None + # test non-initialized server raises RuntimeError with pytest.raises(RuntimeError) as runtime_error: - node_from_device_entry = await get_node_from_device_entry(hass, device_entry) + node_from_device_entry = get_node_from_device_entry(hass, device_entry) assert "Matter server information is not available" in str(runtime_error.value) diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 0d8f892f992..5b343b8c4e5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -1,5 +1,6 @@ """Test Matter sensors.""" -from unittest.mock import MagicMock +from datetime import UTC, datetime, timedelta +from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest @@ -14,6 +15,8 @@ from .common import ( trigger_subscription_callback, ) +from tests.common import async_fire_time_changed + @pytest.fixture(name="flow_sensor_node") async def flow_sensor_node_fixture( @@ -63,6 +66,16 @@ async def temperature_sensor_node_fixture( ) +@pytest.fixture(name="eve_energy_plug_node") +async def eve_energy_plug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Energy Plug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -208,3 +221,70 @@ async def test_battery_sensor( assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_eve_energy_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_energy_plug_node: MatterNode, +) -> None: + """Test Energy sensors created from Eve Energy custom cluster.""" + # power sensor + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == "power" + assert state.attributes["friendly_name"] == "Eve Energy Plug Power" + + # voltage sensor + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "238.800003051758" + assert state.attributes["unit_of_measurement"] == "V" + assert state.attributes["device_class"] == "voltage" + assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" + + # energy sensor + entity_id = "sensor.eve_energy_plug_energy" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.220000028610229" + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == "energy" + assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" + assert state.attributes["state_class"] == "total_increasing" + + # current sensor + entity_id = "sensor.eve_energy_plug_current" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == "A" + assert state.attributes["device_class"] == "current" + assert state.attributes["friendly_name"] == "Eve Energy Plug Current" + + # test if the sensor gets polled on interval + eve_energy_plug_node.update_attribute("1/319486977/319422472", 237.0) + async_fire_time_changed(hass, datetime.now(UTC) + timedelta(seconds=31)) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "237.0" + + # test extra poll triggered when secondary value (switch state) changes + set_node_attribute(eve_energy_plug_node, 1, 6, 0, True) + eve_energy_plug_node.update_attribute("1/319486977/319422474", 5.0) + with patch("homeassistant.components.matter.entity.EXTRA_POLL_DELAY", 0.0): + await trigger_subscription_callback(hass, matter_client) + await hass.async_block_till_done() + entity_id = "sensor.eve_energy_plug_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "5.0" diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 8f877eb1eca..f3d49f3c0bc 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -9,7 +9,9 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.melcloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -287,3 +289,158 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) entry = entries[0] assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["token"] == "test-token" + + +async def test_token_reauthentication( + hass: HomeAssistant, + mock_login, + mock_get_devices, +) -> None: + """Re-configuration with existing username should refresh token, if made invalid.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (asyncio.TimeoutError(), "cannot_connect"), + (AttributeError(name="get"), "invalid_auth"), + ], +) +async def test_form_errors_reauthentication( + hass: HomeAssistant, mock_login, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = error + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == reason + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.FORBIDDEN, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_client_errors_reauthentication( + hass: HomeAssistant, mock_login, mock_request_info, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error) + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == reason + assert result["type"] == FlowResultType.FORM + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index ef03e36343b..2a62fea7f35 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -13,7 +13,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -27,7 +27,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -41,7 +41,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index fed0ae93c66..b0f77f27b80 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -13,7 +13,7 @@ 'state': '5', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -27,7 +27,7 @@ 'state': '3', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -41,7 +41,7 @@ 'state': '10', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -54,7 +54,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -67,7 +67,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -80,7 +80,7 @@ 'state': '123', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -93,7 +93,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -106,7 +106,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -119,7 +119,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -133,7 +133,7 @@ 'state': '5', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -152,7 +152,7 @@ 'state': '3', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -166,7 +166,7 @@ 'state': '10', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -179,7 +179,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -192,7 +192,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -205,7 +205,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -219,7 +219,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -233,7 +233,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -247,7 +247,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -260,7 +260,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -273,7 +273,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -286,7 +286,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -299,7 +299,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -312,7 +312,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -325,7 +325,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -339,7 +339,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -358,7 +358,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -372,7 +372,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -385,7 +385,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -398,7 +398,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 9fae35b113d..4db564bc143 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -22,16 +22,27 @@ from tests.common import async_fire_time_changed @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -41,7 +52,7 @@ async def test_binary_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -53,16 +64,27 @@ async def test_binary_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -73,7 +95,7 @@ async def test_binary_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -88,16 +110,27 @@ async def test_binary_sensor_update( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, freezer: FrozenDateTimeFactory, @@ -107,7 +140,7 @@ async def test_binary_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 785905492c1..2a0208f2251 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -41,7 +41,7 @@ async def test_address_validation_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( @@ -58,7 +58,7 @@ async def test_java_connection_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -95,7 +95,7 @@ async def test_java_connection(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,7 +138,7 @@ async def test_recovery(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py index 6979325fa0c..80b5c91c1fb 100644 --- a/tests/components/minecraft_server/test_diagnostics.py +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -42,9 +42,14 @@ async def test_config_entry_diagnostics( mock_config_entry = request.getfixturevalue(mock_config_entry) mock_config_entry.add_to_hass(hass) + if server.__name__ == "JavaServer": + lookup_function_name = "async_lookup" + else: + lookup_function_name = "lookup" + # Setup mock entry. with patch( - f"mcstatus.server.{server.__name__}.lookup", + f"mcstatus.server.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"mcstatus.server.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 018fdac542e..5b0d9509d69 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -122,7 +122,7 @@ async def test_setup_and_unload_entry( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,14 +138,14 @@ async def test_setup_and_unload_entry( assert java_mock_config_entry.state == ConfigEntryState.NOT_LOADED -async def test_setup_entry_failure( +async def test_setup_entry_lookup_failure( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: - """Test failed entry setup.""" + """Test lookup failure in entry setup.""" java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): assert not await hass.config_entries.async_setup( @@ -156,6 +156,24 @@ async def test_setup_entry_failure( assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR +async def test_setup_entry_init_failure( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test init failure in entry setup.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_initialize", + side_effect=None, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + async def test_setup_entry_not_ready( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: @@ -163,7 +181,7 @@ async def test_setup_entry_not_ready( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -196,7 +214,7 @@ async def test_entry_migration( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry JavaServer(host=TEST_HOST, port=TEST_PORT), # async_migrate_entry @@ -258,7 +276,7 @@ async def test_entry_migration_host_only( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -293,7 +311,7 @@ async def test_entry_migration_v3_failure( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry ValueError, # async_migrate_entry diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index 006c735e034..7d599669d71 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -55,17 +55,25 @@ BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -75,6 +83,7 @@ async def test_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -85,7 +94,7 @@ async def test_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -98,17 +107,25 @@ async def test_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), @@ -118,6 +135,7 @@ async def test_sensor_disabled_by_default( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -127,7 +145,7 @@ async def test_sensor_disabled_by_default( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -141,17 +159,25 @@ async def test_sensor_disabled_by_default( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -161,6 +187,7 @@ async def test_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -172,7 +199,7 @@ async def test_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -189,17 +216,25 @@ async def test_sensor_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -209,6 +244,7 @@ async def test_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -219,7 +255,7 @@ async def test_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index a892dd205fb..e47a6165b30 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,5 +1,4 @@ """Thetests for the Modbus sensor component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, @@ -26,13 +24,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") SLAVE_UNIQUE_ID = "ground_floor_sensor" @@ -57,7 +54,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -69,7 +65,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_DEVICE_ADDRESS: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -196,44 +191,6 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [False * 16], - True, - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_binary_sensor( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index f2de0177c74..325b68869e0 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,17 +1,36 @@ """The tests for the Modbus climate component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, HVACMode, ) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -22,7 +41,6 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, CONF_HVAC_ONOFF_REGISTER, - CONF_LAZY_ERROR, CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, @@ -40,7 +58,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -77,7 +95,6 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_DATA_TYPE: DataType.INT32, - CONF_LAZY_ERROR: 10, } ], }, @@ -186,7 +203,7 @@ async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: ], ) async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> None: - """Run configuration test for mode register.""" + """Run configuration test for HVAC mode register.""" state = hass.states.get(ENTITY_ID) assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] @@ -196,6 +213,47 @@ async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> No assert HVACMode.FAN_ONLY in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_OFF: 1, + CONF_FAN_MODE_AUTO: 2, + CONF_FAN_MODE_LOW: 3, + CONF_FAN_MODE_MEDIUM: 4, + CONF_FAN_MODE_HIGH: 5, + }, + }, + } + ], + }, + ], +) +async def test_config_fan_mode_register(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for Fan mode register.""" + state = hass.states.get(ENTITY_ID) + assert FAN_ON in state.attributes[ATTR_FAN_MODES] + assert FAN_OFF in state.attributes[ATTR_FAN_MODES] + assert FAN_AUTO in state.attributes[ATTR_FAN_MODES] + assert FAN_LOW in state.attributes[ATTR_FAN_MODES] + assert FAN_MEDIUM in state.attributes[ATTR_FAN_MODES] + assert FAN_HIGH in state.attributes[ATTR_FAN_MODES] + assert FAN_TOP not in state.attributes[ATTR_FAN_MODES] + assert FAN_MIDDLE not in state.attributes[ATTR_FAN_MODES] + assert FAN_DIFFUSE not in state.attributes[ATTR_FAN_MODES] + assert FAN_FOCUS not in state.attributes[ATTR_FAN_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -341,6 +399,96 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_LOW, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_MEDIUM, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + FAN_HIGH, + [0x02], + ), + ], +) +async def test_service_climate_fan_update( + hass: HomeAssistant, mock_modbus, mock_ha, result, register_words +) -> None: + """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == result + + @pytest.mark.parametrize( ("temperature", "result", "do_config"), [ @@ -532,10 +680,10 @@ async def test_service_climate_set_temperature( ), ], ) -async def test_service_set_mode( +async def test_service_set_hvac_mode( hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha ) -> None: - """Test set mode.""" + """Test set HVAC mode.""" mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -548,6 +696,69 @@ async def test_service_set_mode( ) +@pytest.mark.parametrize( + ("fan_mode", "result", "do_config"), + [ + ( + FAN_OFF, + [0x02], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ( + FAN_ON, + [0x01], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ], +) +async def test_service_set_fan_mode( + hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha +) -> None: + """Test set Fan mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_fan_mode", + { + "entity_id": ENTITY_ID, + ATTR_FAN_MODE: fan_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} @@ -581,46 +792,6 @@ async def test_restore_state_climate( assert state.attributes[ATTR_TEMPERATURE] == 37 -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_LAZY_ERROR: 1, - } - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x8000], - True, - "17", - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_climate( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index b91b38b1f70..39897822bc8 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -1,5 +1,4 @@ """The tests for the Modbus cover component.""" -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -9,7 +8,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -33,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" @@ -59,7 +57,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -71,7 +68,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -127,45 +123,6 @@ async def test_coil_cover(hass: HomeAssistant, expected, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_COVERS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OPEN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_cover( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 932e07b2d1a..0922329d4b7 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -11,7 +11,6 @@ from homeassistant.components.modbus.const import ( CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -66,7 +65,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, @@ -84,7 +82,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_DEVICE_ADDRESS: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e66115f24d9..df415807119 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,9 +40,19 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_COOL, + CONF_HVAC_MODE_HEAT, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -53,6 +63,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, @@ -68,6 +79,7 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -361,6 +373,25 @@ async def test_duplicate_modbus_validator(do_config) -> None: assert len(do_config) == 1 +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 7, + CONF_FAN_MODE_OFF: 9, + CONF_FAN_MODE_HIGH: 9, + }, + } + ], +) +async def test_duplicate_fan_mode_validator(do_config) -> None: + """Test duplicate modbus validator.""" + duplicate_fan_mode_validator(do_config) + assert len(do_config[CONF_FAN_MODE_VALUES]) == 2 + + @pytest.mark.parametrize( "do_config", [ @@ -404,12 +435,170 @@ async def test_duplicate_modbus_validator(do_config) -> None: ], } ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], ], ) async def test_duplicate_entity_validator(do_config) -> None: """Test duplicate entity validator.""" duplicate_entity_validator(do_config) - assert len(do_config[0][CONF_SENSORS]) == 1 + if CONF_SENSORS in do_config[0]: + assert len(do_config[0][CONF_SENSORS]) == 1 + elif CONF_CLIMATES in do_config[0]: + assert len(do_config[0][CONF_CLIMATES]) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 119, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 99, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 121, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator_with_climate(do_config) -> None: + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_CLIMATES]) == 1 @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 1d6963aaa12..ecd9abd71b8 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -10,7 +10,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -55,7 +54,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index d0a4e23f780..8fb7f9fd951 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the Modbus sensor component.""" import struct -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.modbus.const import ( @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -19,7 +17,6 @@ from homeassistant.components.modbus.const import ( CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -50,7 +47,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import mock_restore_cache_with_extra_data @@ -81,7 +78,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -98,7 +94,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -125,7 +120,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, } ] }, @@ -228,7 +222,6 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "invalid", }, ] @@ -555,7 +548,6 @@ async def test_config_wrong_struct_sensor( ( { CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], False, @@ -1146,41 +1138,6 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 1, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception"), - [ - ( - [0x8000], - True, - ), - ], -) -async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == "17" - await do_next_cycle(hass, mock_do_cycle, 5) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - - @pytest.mark.parametrize( "do_config", [ @@ -1290,7 +1247,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, [0x0102], @@ -1306,7 +1262,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, [0x0102, 0x0304], diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 0eb40d2c082..28c44440581 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest import mock -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -13,7 +12,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -39,7 +37,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import async_fire_time_changed @@ -64,7 +62,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, @@ -227,46 +224,6 @@ async def test_all_switch(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SWITCHES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - CONF_VERIFY: {}, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OFF, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_switch( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "mock_test_state", [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 0664f6e8d6f..cb5ff53d7e9 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from unittest.mock import ANY, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol import yaml @@ -31,6 +32,7 @@ from homeassistant.generated.mqtt import MQTT from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -1320,9 +1322,8 @@ async def help_test_entity_debug_info_max_messages( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(start_dt): for i in range(0, debug_info.STORED_MESSAGES + 1): async_fire_mqtt_message(hass, "test-topic", f"{i}") @@ -1396,7 +1397,7 @@ async def help_test_entity_debug_info_message( debug_info_data = debug_info.info_for_device(hass, device.id) - start_dt = datetime(2019, 1, 1, 0, 0, 0) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) if state_topic is not None: assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -1404,8 +1405,7 @@ async def help_test_entity_debug_info_message( "subscriptions" ] - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): async_fire_mqtt_message(hass, str(state_topic), state_payload) debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1426,8 +1426,7 @@ async def help_test_entity_debug_info_message( expected_transmissions = [] if service: # Trigger an outgoing MQTT message - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): if service: service_data = {ATTR_ENTITY_ID: f"{domain}.beer_test"} if service_parameters: diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index f3bf92951b0..8db1c89bc40 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -3347,6 +3347,11 @@ async def test_set_state_via_stopped_state_no_position_topic( state = hass.states.get("cover.test") assert state.state == STATE_CLOSED + async_fire_mqtt_message(hass, "state-topic", "STOPPED") + + state = hass.states.get("cover.test") + assert state.state == STATE_CLOSED + @pytest.mark.parametrize( "hass_config", diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 21d3bcce3a9..e7c4eba54e2 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -705,8 +705,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -916,11 +917,13 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "auto") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -976,8 +979,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1078,11 +1082,13 @@ async def test_sending_mqtt_command_templates_( assert state.attributes.get(fan.ATTR_PERCENTAGE) == 0 assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1140,8 +1146,9 @@ async def test_sending_mqtt_command_templates_( assert state.state == STATE_ON assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="low") + assert exc.value.translation_key == "not_valid_preset_mode" @pytest.mark.parametrize( @@ -1176,8 +1183,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1276,11 +1284,10 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_turn_on(hass, "fan.test", preset_mode="auto") - assert mqtt_mock.async_publish.call_count == 1 - # We can turn on, but the invalid preset mode will raise - mqtt_mock.async_publish.assert_any_call("command-topic", "ON", 0, False) + assert exc.value.translation_key == "not_valid_preset_mode" + assert mqtt_mock.async_publish.call_count == 0 mqtt_mock.async_publish.reset_mock() await common.async_turn_on(hass, "fan.test", preset_mode="whoosh") @@ -1428,11 +1435,13 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( with pytest.raises(MultipleInvalid): await common.async_set_percentage(hass, "fan.test", 101) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "low") + assert exc.value.translation_key == "not_valid_preset_mode" - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "medium") + assert exc.value.translation_key == "not_valid_preset_mode" await common.async_set_preset_mode(hass, "fan.test", "whoosh") mqtt_mock.async_publish.assert_called_once_with( @@ -1452,8 +1461,9 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.state == STATE_OFF assert state.attributes.get(ATTR_ASSUMED_STATE) - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await common.async_set_preset_mode(hass, "fan.test", "freaking-high") + assert exc.value.translation_key == "not_valid_preset_mode" mqtt_mock.async_publish.reset_mock() state = hass.states.get("fan.test") diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index d31570548f0..98e2c9b71fe 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,6 +8,7 @@ import ssl from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -40,6 +41,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.dt import utcnow from .test_common import help_all_subscribe_calls @@ -3256,6 +3258,7 @@ async def test_debug_info_wildcard( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3279,10 +3282,9 @@ async def test_debug_info_wildcard( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/abc", "123") + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -3304,6 +3306,7 @@ async def test_debug_info_filter_same( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info removes messages with same timestamp.""" await mqtt_mock_entry() @@ -3327,14 +3330,13 @@ async def test_debug_info_filter_same( "subscriptions" ] - dt1 = datetime(2019, 1, 1, 0, 0, 0) - dt2 = datetime(2019, 1, 1, 0, 0, 1) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = dt1 - async_fire_mqtt_message(hass, "sensor/abc", "123") - async_fire_mqtt_message(hass, "sensor/abc", "123") - dt_utcnow.return_value = dt2 - async_fire_mqtt_message(hass, "sensor/abc", "123") + dt1 = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + dt2 = datetime(2019, 1, 1, 0, 0, 1, tzinfo=dt_util.UTC) + freezer.move_to(dt1) + async_fire_mqtt_message(hass, "sensor/abc", "123") + async_fire_mqtt_message(hass, "sensor/abc", "123") + freezer.move_to(dt2) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3364,6 +3366,7 @@ async def test_debug_info_same_topic( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3388,10 +3391,9 @@ async def test_debug_info_same_topic( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3408,16 +3410,16 @@ async def test_debug_info_same_topic( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) async def test_debug_info_qos_retain( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3441,19 +3443,18 @@ async def test_debug_info_qos_retain( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - # simulate the first message was replayed from the broker with retained flag - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) - # simpulate someone else subscribed and retained messages were replayed - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + # simulate the first message was replayed from the broker with retained flag + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) + # simpulate someone else subscribed and retained messages were replayed + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index b3dd3a9a4e3..c5c24c3ae79 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -725,6 +725,93 @@ async def test_controlling_state_via_topic2( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light_rgb/set", + "state_topic": "test_light_rgb/set", + "rgb": True, + "color_temp": True, + "brightness": True, + } + } + } + ], +) +async def test_controlling_the_state_with_legacy_color_handling( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test state updates for lights with a legacy color handling.""" + supported_color_modes = ["color_temp", "hs"] + await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features + assert state.attributes.get("brightness") is None + assert state.attributes.get("color_mode") is None + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") is None + assert state.attributes.get("rgb_color") is None + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("supported_color_modes") == supported_color_modes + assert state.attributes.get("xy_color") is None + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + for _ in range(0, 2): + # Returned state after the light was turned on + # Receiving legacy color mode: rgb. + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, "hue": 16,' + '"saturation": 100, "color": { "r": 255, "g": 67, "b": 0 }, ' + '"bulb_mode": "color", "color_mode": "rgb" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "hs" + assert state.attributes.get("color_temp") is None + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (15.765, 100.0) + assert state.attributes.get("rgb_color") == (255, 67, 0) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.674, 0.322) + + # Returned state after the lights color mode was changed + # Receiving legacy color mode: color_temp + async_fire_mqtt_message( + hass, + "test_light_rgb/set", + '{ "state": "ON", "brightness": 255, "level": 100, ' + '"kelvin": 92, "color_temp": 353, "bulb_mode": "white", ' + '"color_mode": "color_temp" }', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("brightness") == 255 + assert state.attributes.get("color_mode") == "color_temp" + assert state.attributes.get("color_temp") == 353 + assert state.attributes.get("effect") is None + assert state.attributes.get("hs_color") == (28.125, 61.661) + assert state.attributes.get("rgb_color") == (255, 171, 97) + assert state.attributes.get("rgbw_color") is None + assert state.attributes.get("rgbww_color") is None + assert state.attributes.get("xy_color") == (0.513, 0.386) + + @pytest.mark.parametrize( "hass_config", [ @@ -1792,7 +1879,7 @@ async def test_brightness_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 # Test limmiting max brightness async_fire_mqtt_message( @@ -1862,7 +1949,7 @@ async def test_white_scale( state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 128 + assert state.attributes.get("brightness") == 129 @pytest.mark.parametrize( diff --git a/tests/components/osoenergy/__init__.py b/tests/components/osoenergy/__init__.py new file mode 100644 index 00000000000..76d134ef0f5 --- /dev/null +++ b/tests/components/osoenergy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSO Hotwater integration.""" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py new file mode 100644 index 00000000000..d7250356ebe --- /dev/null +++ b/tests/components/osoenergy/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the OSO Energy config flow.""" +from unittest.mock import patch + +from apyosoenergyapi.helper import osoenergy_exceptions + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.osoenergy.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SUBSCRIPTION_KEY = "valid subscription key" +SCAN_INTERVAL = 120 +TEST_USER_EMAIL = "test_user_email@domain.com" +UPDATED_SCAN_INTERVAL = 60 + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ), patch( + "homeassistant.components.osoenergy.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == TEST_USER_EMAIL + assert result2["data"] == { + CONF_API_KEY: SUBSCRIPTION_KEY, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + "entry_id": mock_config.entry_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_exception_on_subscription_key_check( + hass: HomeAssistant, +) -> None: + """Test user flow with invalid username.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + side_effect=osoenergy_exceptions.OSOEnergyReauthRequired(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py index 65bbff0e601..8686c52d79b 100644 --- a/tests/components/ourgroceries/test_todo.py +++ b/tests/components/ourgroceries/test_todo.py @@ -142,12 +142,20 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]] + ("items", "category"), + [ + ( + [{"id": "12345", "name": "Soda", "categoryId": "test_category"}], + "test_category", + ), + ([{"id": "12345", "name": "Uncategorized"}], None), + ], ) async def test_update_todo_item_summary( hass: HomeAssistant, setup_integration: None, ourgroceries: AsyncMock, + category: str | None, ) -> None: """Test for updating an item summary.""" @@ -171,7 +179,7 @@ async def test_update_todo_item_summary( ) assert ourgroceries.change_item_on_list args = ourgroceries.change_item_on_list.call_args - assert args.args == ("test_list", "12345", "test_category", "Milk") + assert args.args == ("test_list", "12345", category, "Milk") @pytest.mark.parametrize( diff --git a/tests/components/peco/test_config_flow.py b/tests/components/peco/test_config_flow.py index 532450f0099..9ce87d707ff 100644 --- a/tests/components/peco/test_config_flow.py +++ b/tests/components/peco/test_config_flow.py @@ -1,6 +1,7 @@ """Test the PECO Outage Counter config flow.""" from unittest.mock import patch +from peco import HttpError, IncompatibleMeterError, UnresponsiveMeterError import pytest from voluptuous.error import MultipleInvalid @@ -17,6 +18,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.FORM assert result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", @@ -35,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "county": "PHILADELPHIA", } + assert result2["context"]["unique_id"] == "PHILADELPHIA" async def test_invalid_county(hass: HomeAssistant) -> None: @@ -43,37 +46,130 @@ async def test_invalid_county(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - with pytest.raises(MultipleInvalid): - await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "county": "INVALID_COUNTY_THAT_SHOULD_NOT_EXIST", - }, - ) - await hass.async_block_till_done() - - second_result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert second_result["type"] == FlowResultType.FORM - assert second_result["errors"] is None + assert result["step_id"] == "user" with patch( "homeassistant.components.peco.async_setup_entry", return_value=True, - ): - second_result2 = await hass.config_entries.flow.async_configure( - second_result["flow_id"], + ), pytest.raises(MultipleInvalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], { - "county": "PHILADELPHIA", + "county": "INVALID_COUNTY_THAT_SHOULDNT_EXIST", }, ) await hass.async_block_till_done() - assert second_result2["type"] == FlowResultType.CREATE_ENTRY - assert second_result2["title"] == "Philadelphia Outage Count" - assert second_result2["data"] == { - "county": "PHILADELPHIA", - } + +async def test_meter_value_error(hass: HomeAssistant) -> None: + """Test if the MeterValueError error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "INVALID_SMART_METER_THAT_SHOULD_NOT_EXIST", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "invalid_phone_number"} + + +async def test_incompatible_meter_error(hass: HomeAssistant) -> None: + """Test if the IncompatibleMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=IncompatibleMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "incompatible_meter" + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if the UnresponsiveMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=UnresponsiveMeterError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "unresponsive_meter"} + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if the InvalidMeter error works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", side_effect=HttpError): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "http_error"} + + +async def test_smart_meter(hass: HomeAssistant) -> None: + """Test if the Smart Meter step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch("peco.PecoOutageApi.meter_check", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "county": "PHILADELPHIA", + "phone_number": "1234567890", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Philadelphia - 1234567890" + assert result["data"]["phone_number"] == "1234567890" + assert result["context"]["unique_id"] == "PHILADELPHIA-1234567890" diff --git a/tests/components/peco/test_init.py b/tests/components/peco/test_init.py index 52a7ddd3b25..2919e508c97 100644 --- a/tests/components/peco/test_init.py +++ b/tests/components/peco/test_init.py @@ -2,7 +2,13 @@ import asyncio from unittest.mock import patch -from peco import AlertResults, BadJSONError, HttpError, OutageResults +from peco import ( + AlertResults, + BadJSONError, + HttpError, + OutageResults, + UnresponsiveMeterError, +) import pytest from homeassistant.components.peco.const import DOMAIN @@ -14,6 +20,7 @@ from tests.common import MockConfigEntry MOCK_ENTRY_DATA = {"county": "TOTAL"} COUNTY_ENTRY_DATA = {"county": "BUCKS"} INVALID_COUNTY_DATA = {"county": "INVALID"} +METER_DATA = {"county": "BUCKS", "phone_number": "1234567890"} async def test_unload_entry(hass: HomeAssistant) -> None: @@ -149,3 +156,154 @@ async def test_bad_json(hass: HomeAssistant, sensor: str) -> None: assert hass.states.get(f"sensor.{sensor}") is None assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_unresponsive_meter_error(hass: HomeAssistant) -> None: + """Test if it raises an error when the meter will not respond.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=UnresponsiveMeterError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_http_error(hass: HomeAssistant) -> None: + """Test if it raises an error when there is an HTTP error.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=HttpError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_bad_json(hass: HomeAssistant) -> None: + """Test if it raises an error when there is bad JSON.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=BadJSONError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_timeout(hass: HomeAssistant) -> None: + """Test if it raises an error when there is a timeout.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + side_effect=asyncio.TimeoutError(), + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is None + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_meter_data(hass: HomeAssistant) -> None: + """Test if the meter returns the value successfully.""" + + config_entry = MockConfigEntry(domain=DOMAIN, data=METER_DATA) + config_entry.add_to_hass(hass) + + with patch( + "peco.PecoOutageApi.meter_check", + return_value=True, + ), patch( + "peco.PecoOutageApi.get_outage_count", + return_value=OutageResults( + customers_out=0, + percent_customers_out=0, + outage_count=0, + customers_served=350394, + ), + ), patch( + "peco.PecoOutageApi.get_map_alerts", + return_value=AlertResults( + alert_content="Testing 1234", alert_title="Testing 4321" + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.meter_status") is not None + assert hass.states.get("binary_sensor.meter_status").state == "on" + assert config_entry.state == ConfigEntryState.LOADED diff --git a/tests/components/philips_js/test_binary_sensor.py b/tests/components/philips_js/test_binary_sensor.py index d11f3fe22f1..01233706d07 100644 --- a/tests/components/philips_js/test_binary_sensor.py +++ b/tests/components/philips_js/test_binary_sensor.py @@ -1,7 +1,7 @@ """The tests for philips_js binary_sensor.""" import pytest -from homeassistant.const import STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from . import MOCK_NAME, MOCK_RECORDINGS_LIST @@ -32,7 +32,16 @@ async def mock_tv_api_valid(mock_tv): return mock_tv -async def test_recordings_list_invalid( +@pytest.fixture +async def mock_tv_recordings_list_unavailable(mock_tv): + """Set up a valid mock_tv with should create sensors.""" + mock_tv.secured_transport = True + mock_tv.api_version = 6 + mock_tv.recordings_list = None + return mock_tv + + +async def test_recordings_list_api_invalid( mock_tv_api_invalid, mock_config_entry, hass: HomeAssistant ) -> None: """Test if sensors are not created if mock_tv is invalid.""" @@ -54,7 +63,21 @@ async def test_recordings_list_valid( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) state = hass.states.get(ID_RECORDING_AVAILABLE) - assert state.state is STATE_ON + assert state.state == STATE_ON state = hass.states.get(ID_RECORDING_ONGOING) - assert state.state is STATE_ON + assert state.state == STATE_ON + + +async def test_recordings_list_unavailable( + mock_tv_recordings_list_unavailable, mock_config_entry, hass: HomeAssistant +) -> None: + """Test if sensors are created correctly.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + state = hass.states.get(ID_RECORDING_AVAILABLE) + assert state.state == STATE_OFF + + state = hass.states.get(ID_RECORDING_ONGOING) + assert state.state == STATE_OFF diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index fb6c99f35e9..1ca6413fc42 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -56,31 +56,16 @@ async def init_integration( return mock_config_entry -@pytest.fixture -def ws_req_id() -> Callable[[], int]: - """Fixture for incremental websocket requests.""" - - id = 0 - - def next_id() -> int: - nonlocal id - id += 1 - return id - - return next_id - - @pytest.fixture async def get_items( - hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] + hass_ws_client: WebSocketGenerator ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" async def get() -> list[dict[str, str]]: # Fetch items using To-do platform client = await hass_ws_client() - id = ws_req_id() - await client.send_json( + await client.send_json_auto_id( { "id": id, "type": "todo/item/list", @@ -88,7 +73,6 @@ async def get_items( } ) resp = await client.receive_json() - assert resp.get("id") == id assert resp.get("success") return resp.get("result", {}).get("items", []) diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py index a65fb83ca95..cdd30967058 100644 --- a/tests/components/picnic/test_todo.py +++ b/tests/components/picnic/test_todo.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.todo import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .conftest import ENTITY_ID @@ -115,7 +116,7 @@ async def test_create_todo_list_item_not_found( mock_picnic_api.search = Mock() mock_picnic_api.search.return_value = [{"items": []}] - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, "add_item", diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index f570a8afc51..2ce320d561b 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -72,7 +72,7 @@ 'domain': 'binary_sensor', 'entity_category': None, 'entity_id': 'binary_sensor.10_10_10_10', - 'has_entity_name': True, + 'has_entity_name': False, 'hidden_by': None, 'icon': None, 'id': , @@ -81,7 +81,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, + 'original_name': '10.10.10.10', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -90,34 +90,6 @@ }) # --- # name: test_setup_and_update.1 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': None, - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'ping', - '10.10.10.10', - ), - }), - 'is_new': False, - 'manufacturer': 'Ping', - 'model': None, - 'name': '10.10.10.10', - 'name_by_user': None, - 'serial_number': None, - 'suggested_area': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- -# name: test_setup_and_update.2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -134,7 +106,7 @@ 'state': 'on', }) # --- -# name: test_setup_and_update.3 +# name: test_setup_and_update.2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py index 5eab92b1139..b1066895e2b 100644 --- a/tests/components/ping/test_binary_sensor.py +++ b/tests/components/ping/test_binary_sensor.py @@ -10,11 +10,7 @@ from syrupy.filters import props from homeassistant.components.ping.const import CONF_IMPORTED_BY, DOMAIN from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -24,7 +20,6 @@ from tests.common import MockConfigEntry async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: @@ -34,10 +29,6 @@ async def test_setup_and_update( entry = entity_registry.async_get("binary_sensor.10_10_10_10") assert entry == snapshot(exclude=props("unique_id")) - # check the device - device = device_registry.async_get_device({(DOMAIN, "10.10.10.10")}) - assert device == snapshot - state = hass.states.get("binary_sensor.10_10_10_10") assert state == snapshot diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index a180e8d745e..5f5bb2132c1 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,17 +1,17 @@ """Test the binary sensor platform of ping.""" -from datetime import timedelta from unittest.mock import patch -from freezegun.api import FrozenDateTimeFactory -from icmplib import Host import pytest +from homeassistant.components.device_tracker import legacy from homeassistant.components.ping.const import DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -19,7 +19,6 @@ async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test sensor setup and update.""" @@ -47,24 +46,6 @@ async def test_setup_and_update( state = hass.states.get("device_tracker.10_10_10_10") assert state.state == "home" - freezer.tick(timedelta(minutes=5)) - await hass.async_block_till_done() - - # check device tracker is still "home" - state = hass.states.get("device_tracker.10_10_10_10") - assert state.state == "home" - - # check if device tracker updates to "not home" - with patch( - "homeassistant.components.ping.helpers.async_ping", - return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), - ): - freezer.tick(timedelta(minutes=5)) - await hass.async_block_till_done() - - state = hass.states.get("device_tracker.10_10_10_10") - assert state.state == "not_home" - async def test_import_issue_creation( hass: HomeAssistant, @@ -79,7 +60,42 @@ async def test_import_issue_creation( ) await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue( HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) assert issue + + +async def test_import_delete_known_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +): + """Test if import deletes known devices.""" + yaml_devices = { + "test": { + "hide_if_away": True, + "mac": "00:11:22:33:44:55", + "name": "Test name", + "picture": "/local/test.png", + "track": True, + }, + } + files = {legacy.YAML_DEVICES: dump(yaml_devices)} + + with patch_yaml_files(files, True), patch( + "homeassistant.components.ping.device_tracker.remove_device_from_config" + ) as remove_device_from_config: + await async_setup_component( + hass, + "device_tracker", + {"device_tracker": {"platform": "ping", "hosts": {"test": "10.10.10.10"}}}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert len(remove_device_from_config.mock_calls) == 1 diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index dacee20c644..37566e1d39e 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -4,6 +4,7 @@ "active_preset": "no_frost", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -99,6 +100,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -155,6 +157,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -265,6 +268,7 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], + "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", @@ -300,6 +304,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", + "item_count": 219, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/adam_jip/device_list.json b/tests/components/plugwise/fixtures/adam_jip/device_list.json new file mode 100644 index 00000000000..049845bc828 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_jip/device_list.json @@ -0,0 +1,13 @@ +[ + "b5c2386c6f6342669e50fe49dd05b188", + "e4684553153b44afbef2200885f379dc", + "a6abc6a129ee499c88a4d420cc413b47", + "1346fbd8498d4dbcab7e18d51b771f3d", + "833de10f269c4deab58fb9df69901b4e", + "6f3e9d7084214c21b9dfa46f6eeb8700", + "f61f1a2535f54f52ad006a3d18e459ca", + "d4496250d0e942cfa7aea3476e9070d5", + "356b65335e274d769c338223e7af9c33", + "1da4d325838e4ad8aac12177214505c9", + "457ce8414de24596a2d5e7dbc9c7682f" +] diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 6e6da1aa272..279fe6b8a43 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -468,6 +468,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", + "item_count": 315, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json new file mode 100644 index 00000000000..104a723e463 --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/device_list.json @@ -0,0 +1,20 @@ +[ + "fe799307f1624099878210aa0b9f1475", + "90986d591dcd426cae3ec3e8111ff730", + "df4a4a8169904cdb9c03d61a21f42140", + "b310b72a0e354bfab43089919b9a88bf", + "a2c3583e0a6349358998b760cea82d2a", + "b59bcebaf94b499ea7d46e4a66fb62d8", + "d3da73bde12a47d5a6b8f9dad971f2ec", + "21f2b542c49845e6bb416884c55778d6", + "78d1126fc4c743db81b61c20e88342a7", + "cd0ddb54ef694e11ac18ed1cbce5dbbd", + "4a810418d5394b3f82727340b91ba740", + "02cf28bfec924855854c544690a609ef", + "a28f588dc4a049a483fd03a30361ad3a", + "6a3bf693d05e48e0b460c815a4fdd09d", + "680423ff840043738f42cc7f1ff97a36", + "f1fee6043d3642a9b0a65297455f008e", + "675416a629f343c495449970e2ca37b5", + "e7693eb9582644e5b865dba8d4447cf1" +] diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 624547155a3..2e1063d14d3 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -53,6 +53,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "cooling", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "cool", @@ -102,6 +103,7 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -148,6 +150,7 @@ "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json new file mode 100644 index 00000000000..f78b4cd38a9 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_cooling/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index e8a72c9b3fb..81d60bed9d4 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -1,5 +1,28 @@ { "devices": { + "01234567890abcdefghijklmnopqrstu": { + "available": false, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "temperature": 18.6, + "temperature_difference": 2.3, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, "056ee145a816487eaa69243c3280f8bf": { "available": true, "binary_sensors": { @@ -58,6 +81,7 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "preheating", "dev_class": "thermostat", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", @@ -101,6 +125,7 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", @@ -147,6 +172,7 @@ "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", + "item_count": 145, "notifications": {}, "smile_name": "Adam" } diff --git a/tests/components/plugwise/fixtures/m_adam_heating/device_list.json b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json new file mode 100644 index 00000000000..f78b4cd38a9 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating/device_list.json @@ -0,0 +1,8 @@ +[ + "da224107914542988a88561b4452b0f6", + "056ee145a816487eaa69243c3280f8bf", + "ad4838d7d35c4d6ea796ee12ae5aedf8", + "1772a4ea304041adb83f357b751341ff", + "e2f4322d57924fa090fbbc48b3a140dc", + "e8ef2a01ed3b4139a53bf749204fe6b4" +] diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json new file mode 100644 index 00000000000..ffb8cf62575 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/device_list.json @@ -0,0 +1,5 @@ +[ + "015ae9ea3f964e668e490fa39da3870b", + "1cbf783bb11e4a7c8a6843dee3a86927", + "3cb70739631c4d17a86b8b12e8a5161b" +] diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json index 0e0b3c51a07..0a47893c077 100644 --- a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json +++ b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json @@ -42,6 +42,7 @@ }, "gateway": { "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", + "item_count": 31, "notifications": {}, "smile_name": "Smile P1" } diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json new file mode 100644 index 00000000000..8af35165c7e --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v3_full_option/device_list.json @@ -0,0 +1 @@ +["cd3e822288064775a7c4afcdd70bdda2", "e950c7d5e1ee407a858e2a8b5016c8b3"] diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index d503bd3a59d..ecda8049163 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -51,6 +51,7 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", + "item_count": 40, "notifications": { "97a04c0c263049b29350a660b4cdd01e": { "warning": "The Smile P1 is not connected to a smart meter." diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json new file mode 100644 index 00000000000..7b301f50924 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/device_list.json @@ -0,0 +1 @@ +["03e65b16e4b247a29ae0d75a78cb492e", "b82b6b3322484f2ea4e25e0bd5f3d61f"] diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index 8604aaae10e..6b1012b0d87 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -135,6 +135,7 @@ }, "gateway": { "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", + "item_count": 83, "notifications": {}, "smile_name": "Stretch" } diff --git a/tests/components/plugwise/fixtures/stretch_v31/device_list.json b/tests/components/plugwise/fixtures/stretch_v31/device_list.json new file mode 100644 index 00000000000..b2c839ae9d3 --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v31/device_list.json @@ -0,0 +1,10 @@ +[ + "0000aaaa0000aaaa0000aaaa0000aa00", + "5871317346d045bc9f6b987ef25ee638", + "e1c884e7dede431dadee09506ec4f859", + "aac7b735042c4832ac9ff33aae4f453b", + "cfe95cf3de1948c0b8955125bf754614", + "059e4d03c7a34d278add5c7a4a781d19", + "d950b314e9d8499f968e6db8d82ef78c", + "d03738edfcc947f7b8f4573571d90d2d" +] diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 597b9710ec5..29f23a137fb 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -500,6 +500,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', + 'item_count': 315, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index c14fd802e3b..c5ab3a209c2 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -65,7 +65,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.anna") assert state assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == "preheating" assert state.attributes["hvac_modes"] == [ HVACMode.OFF, HVACMode.AUTO, @@ -75,7 +75,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.lisa_badkamer") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_action"] == "idle" assert state.attributes["hvac_modes"] == [ HVACMode.OFF, HVACMode.AUTO, @@ -101,7 +101,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"][ "select_regulation_mode" ] = "heating" - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "heat" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "heating" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = False @@ -124,7 +124,7 @@ async def test_adam_3_climate_entity_attributes( data.devices["da224107914542988a88561b4452b0f6"][ "select_regulation_mode" ] = "cooling" - data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["mode"] = "cool" + data.devices["ad4838d7d35c4d6ea796ee12ae5aedf8"]["control_state"] = "cooling" data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ "cooling_state" ] = True diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index d8b30738865..3834254ac7f 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -3,9 +3,8 @@ import time -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth.api import ( async_get_fallback_availability_interval, ) diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 65f08d5653d..e35643d7626 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,10 +1,9 @@ """Tests for sensors.""" +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth import async_set_fallback_availability_interval -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.core import HomeAssistant from . import ( diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index f7bdf232c9e..47204ebf537 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -102,6 +102,18 @@ def mock_connection( ) +def mock_calendar( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection.""" + aioclient_mock.get( + f"{url}/api/v3/calendar", + text=load_fixture("radarr/calendar.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + def mock_connection_error( aioclient_mock: AiohttpClientMocker, url: str = URL, @@ -120,6 +132,7 @@ def mock_connection_invalid_auth( aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/calendar", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -136,6 +149,9 @@ def mock_connection_server_error( aioclient_mock.get( f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/calendar", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -172,6 +188,8 @@ async def setup_integration( single_return=single_return, ) + mock_calendar(aioclient_mock, url) + if not skip_entry_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radarr/fixtures/calendar.json b/tests/components/radarr/fixtures/calendar.json new file mode 100644 index 00000000000..2bf0338d639 --- /dev/null +++ b/tests/components/radarr/fixtures/calendar.json @@ -0,0 +1,111 @@ +[ + { + "title": "test", + "originalTitle": "string", + "alternateTitles": [], + "secondaryYearSourceId": 0, + "sortTitle": "string", + "sizeOnDisk": 0, + "status": "string", + "overview": "test2", + "physicalRelease": "2021-12-03T00:00:00Z", + "digitalRelease": "2020-08-11T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "string", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "0", + "genres": ["string"], + "tags": [], + "added": "2020-07-16T13:25:37Z", + "ratings": { + "imdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "tmdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "metacritic": { + "votes": 0, + "value": 0, + "type": "string" + }, + "rottenTomatoes": { + "votes": 0, + "value": 0, + "type": "string" + } + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 0, + "dateAdded": "2021-06-01T04:08:20Z", + "sceneName": "string", + "indexerFlags": 0, + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 0.0, + "audioCodec": "string", + "audioLanguages": "string", + "audioStreamCount": 0, + "videoBitDepth": 0, + "videoBitrate": 0, + "videoCodec": "string", + "videoFps": 0.0, + "resolution": "string", + "runTime": "00:00:00", + "scanType": "string", + "subtitles": "string" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": false, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "releaseGroup": "string", + "edition": "string", + "id": 0 + }, + "id": 0 + } +] diff --git a/tests/components/radarr/test_binary_sensor.py b/tests/components/radarr/test_binary_sensor.py index b6303de4a48..cd1df721d5f 100644 --- a/tests/components/radarr/test_binary_sensor.py +++ b/tests/components/radarr/test_binary_sensor.py @@ -1,4 +1,6 @@ """The tests for Radarr binary sensor platform.""" +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_binary_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py new file mode 100644 index 00000000000..61e9bc27c9b --- /dev/null +++ b/tests/components/radarr/test_calendar.py @@ -0,0 +1,41 @@ +"""The tests for Radarr calendar platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_calendar( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for successfully setting up the Radarr platform.""" + freezer.move_to("2021-12-02 00:00:00-08:00") + entry = await setup_integration(hass, aioclient_mock) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_ON + assert state.attributes.get("all_day") is True + assert state.attributes.get("description") == "test2" + assert state.attributes.get("end_time") == "2021-12-03 00:00:00" + assert state.attributes.get("message") == "test" + assert state.attributes.get("release_type") == "physicalRelease" + assert state.attributes.get("start_time") == "2021-12-02 00:00:00" + + freezer.tick(timedelta(hours=16)) + await coordinator.async_refresh() + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_OFF + assert len(state.attributes) == 1 + assert state.attributes.get("release_type") is None diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 5527e311114..5eab7c02bb9 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aiopyarr import exceptions +import pytest from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -135,6 +136,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index f16e5895633..62660c12874 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,4 +1,6 @@ """Test Radarr integration.""" +import pytest + from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -9,6 +11,7 @@ from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test unload.""" entry = await setup_integration(hass, aioclient_mock) @@ -43,6 +46,7 @@ async def test_async_setup_entry_auth_failed( assert not hass.data.get(DOMAIN) +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 90ab683037b..11f55b712cd 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -14,6 +14,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") @pytest.mark.parametrize( ("windows", "single", "root_folder"), [ @@ -65,6 +66,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_windows( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 7048e1d63f4..00cbefc6556 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -239,6 +239,14 @@ async def test_fix_unique_id_duplicate( f"{MAC_ADDRESS_UNIQUE_ID}-rain-delay", f"{MAC_ADDRESS_UNIQUE_ID}-1", ), + ( + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + SERIAL_NUMBER, + MAC_ADDRESS_UNIQUE_ID, + MAC_ADDRESS_UNIQUE_ID, + ), ("0", 0, "0", "0", MAC_ADDRESS_UNIQUE_ID, MAC_ADDRESS_UNIQUE_ID), ( "0", @@ -268,6 +276,7 @@ async def test_fix_unique_id_duplicate( ids=( "serial-number", "serial-number-with-suffix", + "serial-number-int", "zero-serial", "zero-serial-suffix", "new-format", diff --git a/tests/components/remote/test_significant_change.py b/tests/components/remote/test_significant_change.py new file mode 100644 index 00000000000..dcbfce213d6 --- /dev/null +++ b/tests/components/remote/test_significant_change.py @@ -0,0 +1,62 @@ +"""Test the Remote significant change platform.""" +from homeassistant.components.remote import ATTR_ACTIVITY_LIST, ATTR_CURRENT_ACTIVITY +from homeassistant.components.remote.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Remote significant changes.""" + # no change at all + attrs = { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + } + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + + # change of state is significant + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + # change of current activity is significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "paused", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + } + assert async_check_significant_change(None, "on", attrs["old"], "on", attrs["new"]) + + # change of list of possible activities is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing"], + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) + + # change of any not official attribute is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + "not_official": "changed", + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 75d2dc0c661..464d4120c65 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -25,6 +25,8 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True +TEST_HOST_MODEL = "RLN8-410" +TEST_CAM_MODEL = "RLC-123" @pytest.fixture @@ -70,8 +72,8 @@ def reolink_connect_class( host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" - host_mock.model = "RLC-123" - host_mock.camera_model.return_value = "RLC-123" + host_mock.model = TEST_HOST_MODEL + host_mock.camera_model.return_value = TEST_CAM_MODEL host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 604a9364320..9f70673695c 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -41,7 +41,7 @@ 'event connection': 'Fast polling', 'firmware version': 'v1.0.0.0.0.0000', 'hardware version': 'IPC_00000', - 'model': 'RLC-123', + 'model': 'RLN8-410', 'stream channels': list([ 0, ]), diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e2bd622bb43..6a9a8b957db 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -11,11 +11,15 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -102,6 +106,7 @@ async def test_entry_reloading( reolink_connect: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" + reolink_connect.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -115,6 +120,58 @@ async def test_entry_reloading( assert config_entry.title == "New Name" +@pytest.mark.parametrize( + ("attr", "value", "expected_models"), + [ + ( + None, + None, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ("channels", [], [TEST_HOST_MODEL]), + ( + "camera_model", + Mock(return_value="RLC-567"), + [TEST_HOST_MODEL, "RLC-567"], + ), + ], +) +async def test_cleanup_disconnected_cams( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + attr: str | None, + value: Any, + expected_models: list[str], +) -> None: + """Test device and entity registry are cleaned up when camera is disconnected from NVR.""" + reolink_connect.channels = [0] + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted([TEST_HOST_MODEL, TEST_CAM_MODEL]) + + # reload integration after 'disconnecting' a camera. + if attr is not None: + setattr(reolink_connect, attr, value) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_reload(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted(expected_models) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index df90af44e73..6224d98f694 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -53,6 +53,22 @@ RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE +@pytest.fixture( + params=( + HTTPStatus.OK, + HTTPStatus.CREATED, + HTTPStatus.ACCEPTED, + HTTPStatus.NON_AUTHORITATIVE_INFORMATION, + HTTPStatus.NO_CONTENT, + HTTPStatus.RESET_CONTENT, + HTTPStatus.PARTIAL_CONTENT, + ) +) +def http_success_code(request: pytest.FixtureRequest) -> HTTPStatus: + """Fixture providing different successful HTTP response code.""" + return request.param + + async def test_setup_missing_config( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -62,8 +78,8 @@ async def test_setup_missing_config( await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) assert ( - "Invalid config for 'switch.rest': required key 'resource' not provided" - in caplog.text + "Invalid config for 'switch' from integration 'rest': required key 'resource' " + "not provided" in caplog.text ) @@ -75,7 +91,10 @@ async def test_setup_missing_schema( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for 'switch.rest': invalid url" in caplog.text + assert ( + "Invalid config for 'switch' from integration 'rest': invalid url" + in caplog.text + ) @respx.mock @@ -259,11 +278,14 @@ async def test_is_on_before_update(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_on_success(hass: HomeAssistant) -> None: +async def test_turn_on_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_on.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, @@ -317,11 +339,14 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_off_success(hass: HomeAssistant) -> None: +async def test_turn_off_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_off.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 4562bf928c8..e5a5c73de39 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -216,7 +216,7 @@ async def test_setup_network_fail(transport_mock, hass: HomeAssistant) -> None: @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @patch( "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ) async def test_setup_serial_fail(com_mock, connect_mock, hass: HomeAssistant) -> None: """Test setup serial failed connection.""" diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 5d4568ce7ac..c186741aac9 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError @@ -153,6 +154,7 @@ async def test_availability( hass: HomeAssistant, mock_roku: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, error: RokuError, ) -> None: """Test entity availability.""" @@ -160,23 +162,22 @@ async def test_availability( future = now + timedelta(minutes=1) mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = error - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE + freezer.move_to(future) + mock_roku.update.side_effect = error + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE future += timedelta(minutes=1) - - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = None - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE + freezer.move_to(future) + mock_roku.update.side_effect = None + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE async def test_supported_features( diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 674dea752a0..27a06ef3a13 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -11,6 +11,7 @@ from async_upnp_client.exceptions import ( UpnpError, UpnpResponseError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from samsungctl import exceptions from samsungtvws.async_remote import SamsungTVWSAsyncRemote @@ -165,7 +166,9 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") -async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_setup_websocket_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Test setup of platform from config entry.""" entity_id = f"{DOMAIN}.fake" @@ -194,9 +197,9 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state @@ -205,7 +208,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Test setup of platform from config entry.""" with patch( @@ -219,9 +222,9 @@ async def test_setup_encrypted_websocket( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state @@ -229,21 +232,25 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") -async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_on( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @pytest.mark.usefixtures("remote") -async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -252,16 +259,20 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: side_effect=[OSError("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -276,9 +287,9 @@ async def test_update_off_ws_no_power_state( remotews.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -287,7 +298,11 @@ async def test_update_off_ws_no_power_state( @pytest.mark.usefixtures("remotews") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" with patch.object( @@ -308,9 +323,9 @@ async def test_update_off_ws_with_power_state( device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info next_update = mock_now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() remotews.start_listening.assert_called_once() rest_api.rest_device_info.assert_called_once() @@ -324,9 +339,9 @@ async def test_update_off_ws_with_power_state( # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() next_update = mock_now + timedelta(minutes=2) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -337,9 +352,9 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" next_update = mock_now + timedelta(minutes=3) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -350,7 +365,11 @@ async def test_update_off_ws_with_power_state( async def test_update_off_encryptedws( - hass: HomeAssistant, remoteencws: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remoteencws: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -364,9 +383,9 @@ async def test_update_off_encryptedws( remoteencws.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -374,7 +393,9 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") -async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_access_denied( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv access denied exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -383,13 +404,14 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> side_effect=exceptions.AccessDenied("Boom"), ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() assert [ flow @@ -403,6 +425,7 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_failure( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_now: datetime, remotews: Mock, caplog: pytest.LogCaptureFixture, @@ -416,8 +439,8 @@ async def test_update_ws_connection_failure( side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert ( @@ -432,7 +455,10 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -441,8 +467,8 @@ async def test_update_ws_connection_closed( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -451,7 +477,10 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -460,8 +489,8 @@ async def test_update_ws_unauthorized_error( remotews, "start_listening", side_effect=UnauthorizedError ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert [ @@ -475,7 +504,7 @@ async def test_update_ws_unauthorized_error( @pytest.mark.usefixtures("remote") async def test_update_unhandled_response( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -485,9 +514,9 @@ async def test_update_unhandled_response( side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -495,7 +524,7 @@ async def test_update_unhandled_response( @pytest.mark.usefixtures("remote") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -505,17 +534,17 @@ async def test_connection_closed_during_update_can_recover( side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -653,7 +682,7 @@ async def test_name(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remote") -async def test_state(hass: HomeAssistant) -> None: +async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test for state property.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.services.async_call( @@ -672,7 +701,8 @@ async def test_state(hass: HomeAssistant) -> None: with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + ): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -1393,7 +1423,11 @@ async def test_upnp_subscribe_events_upnpresponseerror( @pytest.mark.usefixtures("rest_api", "upnp_notify_server") async def test_upnp_re_subscribe_events( - hass: HomeAssistant, remotews: Mock, dmr_device: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + dmr_device: Mock, + mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1407,9 +1441,9 @@ async def test_upnp_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1417,9 +1451,9 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -1434,6 +1468,7 @@ async def test_upnp_re_subscribe_events( ) async def test_upnp_failed_re_subscribe_events( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, mock_now: datetime, @@ -1452,9 +1487,9 @@ async def test_upnp_failed_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1462,9 +1497,8 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch.object( - dmr_device, "async_subscribe_services", side_effect=error - ): + with patch.object(dmr_device, "async_subscribe_services", side_effect=error): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 27f6d7a8e51..12af639b251 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -57,7 +57,7 @@ async def test_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index bd44782f928..9fe5f77f00c 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -108,19 +108,6 @@ async def test_scanner_ignores_wrong_version_and_logs( assert "Unsupported BLE scan result version: 0" in caplog.text -async def test_scanner_minimum_firmware_log_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture -) -> None: - """Test scanner log error if device firmware incompatible.""" - monkeypatch.setattr(mock_rpc_device, "version", "0.11.0") - await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} - ) - assert mock_rpc_device.initialized is True - - assert "BLE not supported on device" in caplog.text - - async def test_scanner_warns_on_corrupt_event( hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 1662405dc80..8a863a852f5 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -153,19 +153,17 @@ MOCK_CONFIG = { "id": 0, "enable": True, "type": "heating", - "actuator": f"shelly://shellywalldisplay-{MOCK_MAC.lower()}/c/switch:0", }, "sys": { "ui_data": {}, "device": {"name": "Test name"}, - "wakeup_period": 0, }, } MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, - "fw": "20201124-092854/v1.9.0@57ac4ad8", + "fw": "20210715-092854/v1.11.0@57ac4ad8", "num_outputs": 2, } @@ -175,13 +173,13 @@ MOCK_SHELLY_RPC = { "mac": MOCK_MAC, "model": MODEL_PLUS_2PM, "gen": 2, - "fw_id": "20220830-130540/0.11.0-gfa1bc37", - "ver": "0.11.0", + "fw_id": "20230803-130540/1.0.0-gfa1bc37", + "ver": "1.0.0", "app": "Plus2PM", "auth_en": False, "auth_domain": None, "profile": "cover", - "relay_operational": False, + "relay_in_thermostat": True, } MOCK_STATUS_COAP = { @@ -291,7 +289,7 @@ async def mock_block_device(): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY_COAP, - version="0.10.0", + version="1.11.0", status=MOCK_STATUS_COAP, firmware_version="some fw string", initialized=True, @@ -315,7 +313,7 @@ def _mock_rpc_device(version: str | None = None): config=MOCK_CONFIG, event={}, shelly=MOCK_SHELLY_RPC, - version=version or "0.12.0", + version=version or "1.0.0", hostname="test-host", status=MOCK_STATUS_RPC, firmware_version="some fw string", @@ -325,23 +323,6 @@ def _mock_rpc_device(version: str | None = None): return device -@pytest.fixture -async def mock_pre_ble_rpc_device(): - """Mock rpc (Gen2, Websocket) device pre BLE.""" - with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: - - def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.STATUS - ) - - device = _mock_rpc_device("0.11.0") - rpc_device_mock.return_value = device - rpc_device_mock.return_value.mock_update = Mock(side_effect=update) - - yield rpc_device_mock.return_value - - @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -364,7 +345,7 @@ async def mock_rpc_device(): {}, RpcUpdateType.DISCONNECTED ) - device = _mock_rpc_device("0.12.0") + device = _mock_rpc_device() rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) rpc_device_mock.return_value.mock_update = Mock(side_effect=update) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 9482080a1a3..c7ac472ada4 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -967,62 +967,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_options_flow_pre_ble_device( - hass: HomeAssistant, mock_pre_ble_rpc_device -) -> None: - """Test setting ble options for gen2 devices with pre ble firmware.""" - entry = await init_integration(hass, 2) - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - await hass.config_entries.async_unload(entry.entry_id) - - async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index e3aea966230..77b65ad3bb5 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -134,7 +134,10 @@ async def test_block_device_rgb_bulb( ColorMode.COLOR_TEMP, ColorMode.RGB, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION + ) assert len(attributes[ATTR_EFFECT_LIST]) == 4 assert attributes[ATTR_EFFECT] == "Off" @@ -232,7 +235,7 @@ async def test_block_device_white_bulb( assert state.state == STATE_ON assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 69e1423f75a..e19416706e1 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -283,7 +283,8 @@ async def test_block_device_gas_valve( async def test_wall_display_thermostat_mode( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, + mock_rpc_device, ) -> None: """Test Wall Display in thermostat mode.""" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -294,7 +295,10 @@ async def test_wall_display_thermostat_mode( async def test_wall_display_relay_mode( - hass: HomeAssistant, entity_registry, mock_rpc_device, monkeypatch + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_rpc_device, + monkeypatch, ) -> None: """Test Wall Display in thermostat mode.""" entity_id = register_entity( @@ -305,8 +309,7 @@ async def test_wall_display_relay_mode( ) new_shelly = deepcopy(mock_rpc_device.shelly) - new_shelly["relay_operational"] = True - + new_shelly["relay_in_thermostat"] = False monkeypatch.setattr(mock_rpc_device, "shelly", new_shelly) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index bca5a5674df..182b45d9c1b 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -1,4 +1,5 @@ """The tests for the notify smtp platform.""" +from pathlib import Path import re from unittest.mock import patch @@ -10,6 +11,7 @@ from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -110,7 +112,7 @@ EMAIL_DATA = [ ), ( "Test msg", - {"html": HTML, "images": ["test.jpg"]}, + {"html": HTML, "images": ["tests/testing_config/notify/test_not_exists.jpg"]}, "Content-Type: multipart/related", ), ( @@ -132,15 +134,52 @@ EMAIL_DATA = [ ], ) def test_send_message( - message_data, data, content_type, hass: HomeAssistant, message + hass: HomeAssistant, message_data, data, content_type, message ) -> None: """Verify if we can send messages of all types correctly.""" sample_email = "" + message.hass = hass + hass.config.allowlist_external_dirs.add(Path("tests/testing_config").resolve()) with patch("email.utils.make_msgid", return_value=sample_email): result, _ = message.send_message(message_data, data=data) assert content_type in result +@pytest.mark.parametrize( + ("message_data", "data", "content_type"), + [ + ( + "Test msg", + {"images": ["tests/testing_config/notify/test.jpg"]}, + "Content-Type: multipart/mixed", + ), + ], +) +def test_sending_insecure_files_fails( + hass: HomeAssistant, + message_data, + data, + content_type, + message, +) -> None: + """Verify if we cannot send messages with insecure attachments.""" + sample_email = "" + message.hass = hass + with patch("email.utils.make_msgid", return_value=sample_email), pytest.raises( + ServiceValidationError + ) as exc: + result, _ = message.send_message(message_data, data=data) + assert content_type in result + assert exc.value.translation_key == "remote_path_not_allowed" + assert exc.value.translation_domain == DOMAIN + assert ( + str(exc.value.translation_placeholders["file_path"]) + == "tests/testing_config/notify" + ) + assert exc.value.translation_placeholders["url"] + assert exc.value.translation_placeholders["file_name"] == "test.jpg" + + def test_send_text_message(hass: HomeAssistant, message) -> None: """Verify if we can send simple text message.""" expected = ( diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cb988d3f2d4..cdc9a8e07a6 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -5,6 +5,7 @@ from datetime import timedelta from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError @@ -12,6 +13,7 @@ from sqlalchemy.exc import SQLAlchemyError from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import CONF_QUERY, DOMAIN +from homeassistant.components.sql.sensor import _generate_lambda_stmt from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_ICON, @@ -21,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -570,3 +573,48 @@ async def test_attributes_from_entry_config( assert state.attributes["unit_of_measurement"] == "MiB" assert "device_class" not in state.attributes assert "state_class" not in state.attributes + + +async def test_query_recover_from_rollback( + recorder_mock: Recorder, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the SQL sensor.""" + config = { + "db_url": "sqlite://", + "query": "SELECT 5 as value", + "column": "value", + "name": "Select value SQL query", + "unique_id": "very_unique_id", + } + await init_integration(hass, config) + platforms = async_get_platforms(hass, "sql") + sql_entity = platforms[0].entities["sensor.select_value_sql_query"] + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes["value"] == 5 + + with patch.object( + sql_entity, + "_lambda_stmt", + _generate_lambda_stmt("Faulty syntax create operational issue"), + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert "sqlite3.OperationalError" in caplog.text + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") is None + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.select_value_sql_query") + assert state.state == "5" + assert state.attributes.get("value") == 5 diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 87ba2d3be73..f12c7750cdf 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,7 +6,7 @@ from pysqueezebox import Server from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.squeezebox.const import DOMAIN +from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -59,7 +59,13 @@ async def test_user_form(hass: HomeAssistant) -> None: # test the edit step result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: ""}, + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -68,6 +74,7 @@ async def test_user_form(hass: HomeAssistant) -> None: CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: "", + CONF_HTTPS: False, } await hass.async_block_till_done() @@ -107,7 +114,11 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: "homeassistant.components.squeezebox.async_setup_entry", return_value=True, ): - entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UUID, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, + ) await hass.config_entries.async_add(entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -186,7 +197,7 @@ async def test_discovery_no_uuid(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "edit" diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py new file mode 100644 index 00000000000..1453483a3fd --- /dev/null +++ b/tests/components/sunweg/__init__.py @@ -0,0 +1 @@ +"""Tests for the sunweg component.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py new file mode 100644 index 00000000000..075af21f74b --- /dev/null +++ b/tests/components/sunweg/common.py @@ -0,0 +1,63 @@ +"""Common functions needed to setup tests for Sun WEG.""" +from datetime import datetime + +from sunweg.device import MPPT, Inverter, Phase, String +from sunweg.plant import Plant + +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +FIXTURE_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", +} + +SUNWEG_PLANT_RESPONSE = Plant( + 123456, + "Plant #123", + 29.5, + 0.5, + 0, + 12.786912, + 24.0, + "kWh", + 332.2, + 0.012296, + datetime(2023, 2, 16, 14, 22, 37), +) + +SUNWEG_INVERTER_RESPONSE = Inverter( + 21255, + "INVERSOR01", + "J63T233018RE074", + 23.2, + 0.0, + 0.0, + "MWh", + 0, + "kWh", + 0.0, + 1, + 0, + "kW", +) + +SUNWEG_PHASE_RESPONSE = Phase("PhaseA", 120.0, 3.2, 0, 0) + +SUNWEG_MPPT_RESPONSE = MPPT("MPPT1") + +SUNWEG_STRING_RESPONSE = String("STR1", 450.3, 23.4, 0) + +SUNWEG_LOGIN_RESPONSE = True + +SUNWEG_MOCK_ENTRY = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_PLANT_ID: 0, + CONF_NAME: "Name", + }, +) diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py new file mode 100644 index 00000000000..64d7816f077 --- /dev/null +++ b/tests/components/sunweg/test_config_flow.py @@ -0,0 +1,135 @@ +"""Tests for the Sun WEG server config flow.""" +from copy import deepcopy +from unittest.mock import patch + +from sunweg.api import APIHelper + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .common import FIXTURE_USER_INPUT, SUNWEG_LOGIN_RESPONSE, SUNWEG_PLANT_RESPONSE + +from tests.common import MockConfigEntry + + +async def test_show_authenticate_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_incorrect_login(hass: HomeAssistant) -> None: + """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch.object(APIHelper, "authenticate", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_no_plants_on_account(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=[]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_plants" + + +async def test_multiple_plant_ids(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + plant_list = [deepcopy(SUNWEG_PLANT_RESPONSE), deepcopy(SUNWEG_PLANT_RESPONSE)] + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=plant_list): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "plant" + + user_input = {CONF_PLANT_ID: 123456} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_one_plant_on_account(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow with an entered plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object( + APIHelper, + "listPlants", + return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_existing_plant_configured(hass: HomeAssistant) -> None: + """Test entering an existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = FIXTURE_USER_INPUT.copy() + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object( + APIHelper, + "listPlants", + return_value=[deepcopy(SUNWEG_PLANT_RESPONSE)], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py new file mode 100644 index 00000000000..fd9ab5ce895 --- /dev/null +++ b/tests/components/sunweg/test_init.py @@ -0,0 +1,144 @@ +"""Tests for the Sun WEG init.""" + +from copy import deepcopy +import json +from unittest.mock import MagicMock, patch + +from sunweg.api import APIHelper +from sunweg.device import MPPT, Inverter +from sunweg.plant import Plant + +from homeassistant.components.sunweg import SunWEGData +from homeassistant.components.sunweg.const import DOMAIN +from homeassistant.components.sunweg.sensor_types.sensor_entity_description import ( + SunWEGSensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + SUNWEG_INVERTER_RESPONSE, + SUNWEG_LOGIN_RESPONSE, + SUNWEG_MOCK_ENTRY, + SUNWEG_MPPT_RESPONSE, + SUNWEG_PHASE_RESPONSE, + SUNWEG_PLANT_RESPONSE, + SUNWEG_STRING_RESPONSE, +) + + +async def test_methods(hass: HomeAssistant) -> None: + """Test methods.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + mppt: MPPT = deepcopy(SUNWEG_MPPT_RESPONSE) + mppt.strings.append(SUNWEG_STRING_RESPONSE) + inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) + inverter.phases.append(SUNWEG_PHASE_RESPONSE) + inverter.mppts.append(mppt) + plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) + plant.inverters.append(inverter) + + with patch.object( + APIHelper, "authenticate", return_value=SUNWEG_LOGIN_RESPONSE + ), patch.object(APIHelper, "listPlants", return_value=[plant]), patch.object( + APIHelper, "plant", return_value=plant + ), patch.object(APIHelper, "inverter", return_value=inverter), patch.object( + APIHelper, "complete_inverter" + ): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mock_entry.entry_id) + + +async def test_setup_wrongpass(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object(APIHelper, "authenticate", return_value=False): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + +async def test_sunwegdata_update_exception() -> None: + """Test SunWEGData exception on update.""" + api = MagicMock() + api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) + data = SunWEGData(api, 0) + data.update() + assert data.data is None + + +async def test_sunwegdata_update_success() -> None: + """Test SunWEGData success on update.""" + inverter: Inverter = deepcopy(SUNWEG_INVERTER_RESPONSE) + plant: Plant = deepcopy(SUNWEG_PLANT_RESPONSE) + plant.inverters.append(inverter) + api = MagicMock() + api.plant = MagicMock(return_value=plant) + api.complete_inverter = MagicMock() + data = SunWEGData(api, 0) + data.update() + assert data.data.id == plant.id + assert data.data.name == plant.name + assert data.data.kwh_per_kwp == plant.kwh_per_kwp + assert data.data.last_update == plant.last_update + assert data.data.performance_rate == plant.performance_rate + assert data.data.saving == plant.saving + assert len(data.data.inverters) == 1 + + +async def test_sunwegdata_get_api_value_none() -> None: + """Test SunWEGData none return on get_api_value.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.data = deepcopy(SUNWEG_PLANT_RESPONSE) + assert data.get_api_value("variable", "inverter", 0, "deep_name") is None + data.data.inverters.append(deepcopy(SUNWEG_INVERTER_RESPONSE)) + assert data.get_api_value("variable", "invalid type", 21255, "deep_name") is None + + +async def test_sunwegdata_get_data_drop_threshold() -> None: + """Test SunWEGData get_data with drop threshold.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key" + ) + entity_description.previous_value_drop_threshold = 0.1 + data.get_api_value.return_value = 3.0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.91 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.8 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 2.8 + ) + + +async def test_sunwegdata_get_data_never_reset() -> None: + """Test SunWEGData get_data with never reset.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key" + ) + entity_description.never_resets = True + data.get_api_value.return_value = 3.0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 0 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 3.0 + ) + data.get_api_value.return_value = 2.8 + assert ( + data.get_data(entity_description=entity_description, device_type="total") == 2.8 + ) diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 3e034d2b9f2..5d54f31b13a 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -131,5 +131,5 @@ async def test_tag_id_exists( await client.send_json({"id": 2, "type": f"{DOMAIN}/create", "tag_id": "test tag"}) response = await client.receive_json() assert not response["success"] - assert response["error"]["code"] == "unknown_error" + assert response["error"]["code"] == "home_assistant_error" assert len(changes) == 0 diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 2bfb4a9d5e2..d5f1e4d7101 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -31,6 +31,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -313,6 +315,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -323,6 +340,18 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.BINARY_SENSOR, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability when deep sleep is enabled.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + config["swn"][0] = "Test" + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.BINARY_SENSOR, config + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index a184f650fae..1f414cb4e5a 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -4,6 +4,7 @@ import json from unittest.mock import ANY from hatasmota.const import ( + CONF_DEEP_SLEEP, CONF_MAC, CONF_OFFLINE, CONF_ONLINE, @@ -188,6 +189,76 @@ async def help_test_availability_when_connection_lost( assert state.state != STATE_UNAVAILABLE +async def help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability after MQTT disconnection when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + # Device online + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Disconnected from MQTT server -> state changed to unavailable + mqtt_mock.connected = False + await hass.async_add_executor_job(mqtt_client_mock.on_disconnect, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state == STATE_UNAVAILABLE + + # Reconnected to MQTT server -> state no longer unavailable + mqtt_mock.connected = True + await hass.async_add_executor_job(mqtt_client_mock.on_connect, None, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + # Receive LWT again + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability( hass, mqtt_mock, @@ -236,6 +307,55 @@ async def help_test_availability( assert state.state == STATE_UNAVAILABLE +async def help_test_deep_sleep_availability( + hass, + mqtt_mock, + domain, + config, + sensor_config=None, + object_id="tasmota_test", +): + """Test availability when deep sleep is enabled. + + This is a test helper for the TasmotaAvailability mixin. + """ + config[CONF_DEEP_SLEEP] = 1 + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_online(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + config_get_state_offline(config), + ) + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.{object_id}") + assert state.state != STATE_UNAVAILABLE + + async def help_test_availability_discovery_update( hass, mqtt_mock, diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index e2bdc8b2ca7..cae65521e21 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -663,6 +665,27 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.COVER, + config, + object_id="test_cover_1", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -676,6 +699,19 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Test" + config["rl"][0] = 3 + config["rl"][1] = 3 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 2a50e2d43b5..05e3151be2e 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -232,6 +234,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -243,6 +259,17 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["if"] = 1 + await help_test_deep_sleep_availability( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 27b7bd1a82a..50f11fb7757 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -22,6 +22,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1669,6 +1671,21 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1679,6 +1696,16 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.LIGHT, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 2 + config["lt_st"] = 1 # 1 channel light (Dimmer) + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.LIGHT, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 2f50a84ffdd..dc4820779a6 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -28,6 +28,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -1222,6 +1224,26 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -1238,6 +1260,22 @@ async def test_availability( ) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_deep_sleep_availability( + hass, + mqtt_mock, + Platform.SENSOR, + config, + sensor_config, + "tasmota_dht11_temperature", + ) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 54d94b46fe8..1a16f372fc9 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -20,6 +20,8 @@ from .test_common import ( help_test_availability_discovery_update, help_test_availability_poll_state, help_test_availability_when_connection_lost, + help_test_deep_sleep_availability, + help_test_deep_sleep_availability_when_connection_lost, help_test_discovery_device_remove, help_test_discovery_removal, help_test_discovery_update_unchanged, @@ -158,6 +160,20 @@ async def test_availability_when_connection_lost( ) +async def test_deep_sleep_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock: MqttMockHAClient, + setup_tasmota, +) -> None: + """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config + ) + + async def test_availability( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: @@ -167,6 +183,15 @@ async def test_availability( await help_test_availability(hass, mqtt_mock, Platform.SWITCH, config) +async def test_deep_sleep_availability( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.SWITCH, config) + + async def test_availability_discovery_update( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index bfdb9352767..ece568eee49 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -1,6 +1,7 @@ """The tests for the Template button platform.""" import datetime as dt -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -59,7 +60,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: assert hass.states.async_all("button") == [] -async def test_all_optional_config(hass: HomeAssistant, calls) -> None: +async def test_all_optional_config( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test: including all optional templates is ok.""" with assert_setup_component(1, "template"): assert await setup.async_setup_component( @@ -98,14 +101,13 @@ async def test_all_optional_config(hass: HomeAssistant, calls) -> None: ) now = dt.datetime.now(dt.UTC) - - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, + blocking=True, + ) assert len(calls) == 1 assert calls[0].data["caller"] == _TEST_OPTIONS_BUTTON diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index f4cfe90b9f0..b95a68afd85 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -845,4 +845,4 @@ async def test_option_flow_sensor_preview_config_entry_removed( ) msg = await client.receive_json() assert not msg["success"] - assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 35f03ee9508..88f0fc366a3 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -424,7 +424,7 @@ async def test_template_open_or_position( ) -> None: """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all("cover") == [] - assert "Invalid config for 'cover.template'" in caplog_setup_text + assert "Invalid config for 'cover' from integration 'template'" in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index f9b0bddddcf..ccdafebd8bb 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -12,6 +12,7 @@ from homeassistant.components.fan import ( DIRECTION_REVERSE, DOMAIN, FanEntityFeature, + NotValidPresetModeError, ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -489,7 +490,11 @@ async def test_preset_modes(hass: HomeAssistant, calls) -> None: ("smart", "smart", 3), ("invalid", "smart", 3), ]: - await common.async_set_preset_mode(hass, _TEST_FAN, extra) + if extra != state: + with pytest.raises(NotValidPresetModeError): + await common.async_set_preset_mode(hass, _TEST_FAN, extra) + else: + await common.async_set_preset_mode(hass, _TEST_FAN, extra) assert hass.states.get(_PRESET_MODE_INPUT_SELECT).state == state assert len(calls) == expected_calls assert calls[-1].data["action"] == "set_preset_mode" @@ -550,6 +555,7 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: with assert_setup_component(1, "fan"): test_fan_config = { "preset_mode_template": "{{ states('input_select.preset_mode') }}", + "preset_modes": ["auto"], "percentage_template": "{{ states('input_number.percentage') }}", "oscillating_template": "{{ states('input_select.osc') }}", "direction_template": "{{ states('input_select.direction') }}", @@ -625,18 +631,18 @@ async def test_no_value_template(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() await common.async_turn_on(hass, _TEST_FAN) - _verify(hass, STATE_ON, 0, None, None, None) + _verify(hass, STATE_ON, 0, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, 0, None, None, None) + _verify(hass, STATE_OFF, 0, None, None, "auto") percent = 100 await common.async_set_percentage(hass, _TEST_FAN, percent) assert int(float(hass.states.get(_PERCENTAGE_INPUT_NUMBER).state)) == percent - _verify(hass, STATE_ON, percent, None, None, None) + _verify(hass, STATE_ON, percent, None, None, "auto") await common.async_turn_off(hass, _TEST_FAN) - _verify(hass, STATE_OFF, percent, None, None, None) + _verify(hass, STATE_OFF, percent, None, None, "auto") preset = "auto" await common.async_set_preset_mode(hass, _TEST_FAN, preset) diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index 84fdadfec0d..af010c57e2e 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -1,8 +1,8 @@ """The tests for the Template automation.""" from datetime import timedelta from unittest import mock -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import homeassistant.components.automation as automation @@ -803,56 +803,56 @@ async def test_invalid_for_template_1(hass: HomeAssistant, start_ha, calls) -> N assert mock_logger.error.called -async def test_if_fires_on_time_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_time_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing on time changes.""" start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ utcnow().minute % 2 == 0 }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() - assert len(calls) == 0 + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ utcnow().minute % 2 == 0 }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 # Trigger once (match template) first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) second_time = start_time.replace(minute=4, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=second_time): - async_fire_time_changed(hass, second_time) - await hass.async_block_till_done() + freezer.move_to(second_time) + async_fire_time_changed(hass, second_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (do not match template) third_time = start_time.replace(minute=5, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=third_time): - async_fire_time_changed(hass, third_time) - await hass.async_block_till_done() + freezer.move_to(third_time) + async_fire_time_changed(hass, third_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) forth_time = start_time.replace(minute=8, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=forth_time): - async_fire_time_changed(hass, forth_time) - await hass.async_block_till_done() + freezer.move_to(forth_time) + async_fire_time_changed(hass, forth_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/components/tessie/__init__.py b/tests/components/tessie/__init__.py new file mode 100644 index 00000000000..df17fe027d9 --- /dev/null +++ b/tests/components/tessie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tessie integration.""" diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py new file mode 100644 index 00000000000..572a687a6e5 --- /dev/null +++ b/tests/components/tessie/common.py @@ -0,0 +1,55 @@ +"""Tessie common helpers for tests.""" + +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp import ClientConnectionError, ClientResponseError +from aiohttp.client import RequestInfo + +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) +TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) +TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) + +TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} +TESSIE_URL = "https://api.tessie.com/" + +TEST_REQUEST_INFO = RequestInfo( + url=TESSIE_URL, method="GET", headers={}, real_url=TESSIE_URL +) + +ERROR_AUTH = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.UNAUTHORIZED +) +ERROR_TIMEOUT = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.REQUEST_TIMEOUT +) +ERROR_UNKNOWN = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.BAD_REQUEST +) +ERROR_CONNECTION = ClientConnectionError() + + +async def setup_platform(hass: HomeAssistant, side_effect=None): + """Set up the Tessie platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tessie.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + side_effect=side_effect, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/tessie/fixtures/asleep.json b/tests/components/tessie/fixtures/asleep.json new file mode 100644 index 00000000000..4f78efafcf1 --- /dev/null +++ b/tests/components/tessie/fixtures/asleep.json @@ -0,0 +1 @@ +{ "state": "asleep" } diff --git a/tests/components/tessie/fixtures/online.json b/tests/components/tessie/fixtures/online.json new file mode 100644 index 00000000000..8fbab1ab948 --- /dev/null +++ b/tests/components/tessie/fixtures/online.json @@ -0,0 +1,276 @@ +{ + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" +} diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json new file mode 100644 index 00000000000..9cc833a1cb2 --- /dev/null +++ b/tests/components/tessie/fixtures/vehicles.json @@ -0,0 +1,292 @@ +{ + "results": [ + { + "vin": "VINVINVIN", + "is_active": true, + "is_archived_manually": false, + "last_charge_created_at": null, + "last_charge_updated_at": null, + "last_drive_created_at": null, + "last_drive_updated_at": null, + "last_idle_created_at": null, + "last_idle_updated_at": null, + "last_state": { + "id": 123456789, + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" + } + } + ] +} diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py new file mode 100644 index 00000000000..d1977a13193 --- /dev/null +++ b/tests/components/tessie/test_config_flow.py @@ -0,0 +1,240 @@ +"""Test the Tessie config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + TEST_CONFIG, + TEST_STATE_OF_ALL_VEHICLES, + setup_platform, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles function.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles: + yield mock_get_state_of_all_vehicles + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles, patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tessie" + assert result2["data"] == TEST_CONFIG + + +async def test_form_invalid_access_token(hass: HomeAssistant) -> None: + """Test invalid auth is handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_AUTH, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_invalid_response(hass: HomeAssistant) -> None: + """Test invalid auth is handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_UNKNOWN, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_network_issue(hass: HomeAssistant) -> None: + """Test network issues are handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + side_effect=ERROR_CONNECTION, + ): + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Complete the flow + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + assert result1["type"] == FlowResultType.FORM + assert result1["step_id"] == "reauth_confirm" + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {"base": "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error +) -> None: + """Test reauth flows that failscript/.""" + + mock_entry = await setup_platform(hass) + mock_get_state_of_all_vehicles.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert "errors" not in result3 + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py new file mode 100644 index 00000000000..8fe92454c36 --- /dev/null +++ b/tests/components/tessie/test_coordinator.py @@ -0,0 +1,104 @@ +"""Test the Tessie sensor platform.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.components.tessie.sensor import TessieStatus +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_TIMEOUT, + ERROR_UNKNOWN, + TEST_VEHICLE_STATE_ASLEEP, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + + +@pytest.fixture +def mock_get_state(): + """Mock get_state function.""" + with patch( + "homeassistant.components.tessie.coordinator.get_state", + ) as mock_get_state: + yield mock_get_state + + +async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles online vehciles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.ONLINE + + +async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles asleep vehicles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.ASLEEP + + +async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles client errors.""" + + mock_get_state.side_effect = ERROR_UNKNOWN + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE + + +async def test_coordinator_timeout(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_TIMEOUT + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == TessieStatus.OFFLINE + + +async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_AUTH + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + + +async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles connection errors.""" + + mock_get_state.side_effect = ERROR_CONNECTION + await setup_platform(hass) + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("sensor.test_status").state == STATE_UNAVAILABLE diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py new file mode 100644 index 00000000000..8c12979b9d5 --- /dev/null +++ b/tests/components/tessie/test_init.py @@ -0,0 +1,37 @@ +"""Test the Tessie init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform + + +async def test_load_unload(hass: HomeAssistant) -> None: + """Test load and unload.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_AUTH) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unknown_failure(hass: HomeAssistant) -> None: + """Test init with an authentication failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_connection_failure(hass: HomeAssistant) -> None: + """Test init with a network connection failure.""" + + entry = await setup_platform(hass, side_effect=ERROR_CONNECTION) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py new file mode 100644 index 00000000000..b9371032d0e --- /dev/null +++ b/tests/components/tessie/test_sensor.py @@ -0,0 +1,24 @@ +"""Test the Tessie sensor platform.""" +from homeassistant.components.tessie.sensor import DESCRIPTIONS +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_sensors(hass: HomeAssistant) -> None: + """Tests that the sensors are correct.""" + + assert len(hass.states.async_all("sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("sensor")) == len(DESCRIPTIONS) + + assert hass.states.get("sensor.test_battery_level").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_level"] + ) + assert hass.states.get("sensor.test_charge_energy_added").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_energy_added"] + ) + assert hass.states.get("sensor.test_shift_state").state == STATE_UNKNOWN diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 96c7edf422b..f9ef8a7cfe9 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,29 +1,30 @@ """The tests for time_date sensor platform.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -async def test_intervals(hass: HomeAssistant) -> None: +async def test_intervals(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test timing intervals of sensors.""" device = time_date.TimeDateSensor(hass, "time") now = dt_util.utc_from_timestamp(45.5) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(60) device = time_date.TimeDateSensor(hass, "beat") now = dt_util.parse_datetime("2020-11-13 00:00:29+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00") device = time_date.TimeDateSensor(hass, "date_time") now = dt_util.utc_from_timestamp(1495068899) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(1495068900) now = dt_util.utcnow() @@ -102,14 +103,16 @@ async def test_states_non_default_timezone(hass: HomeAssistant) -> None: assert device.state == "2017-05-17T20:54:00" -async def test_timezone_intervals(hass: HomeAssistant) -> None: +async def test_timezone_intervals( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test date sensor behavior in a timezone besides UTC.""" hass.config.set_time_zone("America/New_York") device = time_date.TimeDateSensor(hass, "date") now = dt_util.utc_from_timestamp(50000) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() # start of local day in EST was 18000.0 # so the second day was 18000 + 86400 assert next_time.timestamp() == 104400 @@ -117,43 +120,40 @@ async def test_timezone_intervals(hass: HomeAssistant) -> None: hass.config.set_time_zone("America/Edmonton") now = dt_util.parse_datetime("2017-11-13 19:47:19-07:00") device = time_date.TimeDateSensor(hass, "date") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") # Entering DST hass.config.set_time_zone("Europe/Prague") now = dt_util.parse_datetime("2020-03-29 00:00+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") now = dt_util.parse_datetime("2020-03-29 03:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") # Leaving DST now = dt_util.parse_datetime("2020-10-25 00:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") now = dt_util.parse_datetime("2020-10-25 23:59+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") -@patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.parse_datetime("2017-11-14 02:47:19-00:00"), -) async def test_timezone_intervals_empty_parameter( - utcnow_mock, hass: HomeAssistant + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test get_interval() without parameters.""" + freezer.move_to(dt_util.parse_datetime("2017-11-14 02:47:19-00:00")) hass.config.set_time_zone("America/Edmonton") device = time_date.TimeDateSensor(hass, "date") next_time = device.get_next_interval() diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 0071d4ada86..90b06858e00 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -354,10 +354,10 @@ async def test_add_item_service_raises( ( { "item": "Submit forms", - "due_date_time": f"2023-11-17T17:00:00{TEST_OFFSET}", + "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", }, ValueError, - "does not support setting field 'due_date_time'", + "does not support setting field 'due_datetime'", ), ], ) @@ -396,7 +396,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + {"item": "New item", "due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -405,7 +405,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": "2023-11-13T17:00:00+00:00"}, + {"item": "New item", "due_datetime": "2023-11-13T17:00:00+00:00"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -414,7 +414,7 @@ async def test_add_item_service_invalid_input( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"item": "New item", "due_date_time": "2023-11-13"}, + {"item": "New item", "due_datetime": "2023-11-13"}, TodoItem( summary="New item", status=TodoItemStatus.NEEDS_ACTION, @@ -663,7 +663,7 @@ async def test_update_item_service_invalid_input( @pytest.mark.parametrize( ("update_data"), [ - ({"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}), + ({"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}), ({"due_date": "2023-11-13"}), ({"description": "Submit revised draft"}), ], @@ -697,7 +697,7 @@ async def test_update_todo_item_field_unsupported( ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, - {"due_date_time": f"2023-11-13T17:00:00{TEST_OFFSET}"}, + {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( uid="1", due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 28f22e1061a..42251b0ea18 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -45,6 +45,8 @@ def make_api_task( is_completed: bool = False, due: Due | None = None, project_id: str | None = None, + description: str | None = None, + parent_id: str | None = None, ) -> Task: """Mock a todoist Task instance.""" return Task( @@ -55,12 +57,12 @@ def make_api_task( content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", - description="A task", - due=due or Due(is_recurring=False, date=TODAY, string="today"), + description=description, + due=due, id=id or "1", labels=["Label1"], order=1, - parent_id=None, + parent_id=parent_id, priority=1, project_id=project_id or PROJECT_ID, section_id=None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index fb6f707be47..1e94b52149c 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -1,7 +1,9 @@ """Unit tests for the Todoist todo platform.""" +from typing import Any from unittest.mock import AsyncMock import pytest +from todoist_api_python.models import Due, Task from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform @@ -19,6 +21,12 @@ def platforms() -> list[Platform]: return [Platform.TODO] +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant) -> None: + """Set the time zone for the tests that keesp UTC-6 all year round.""" + hass.config.set_time_zone("America/Regina") + + @pytest.mark.parametrize( ("tasks", "expected_state"), [ @@ -43,6 +51,14 @@ def platforms() -> list[Platform]: ], "0", ), + ( + [ + make_api_task( + id="12345", content="sub-task", is_completed=False, parent_id="1" + ) + ], + "0", + ), ], ) async def test_todo_item_state( @@ -57,11 +73,91 @@ async def test_todo_item_state( assert state.state == expected_state -@pytest.mark.parametrize(("tasks"), [[]]) +@pytest.mark.parametrize( + ("tasks", "item_data", "tasks_after_update", "add_kwargs", "expected_item"), + [ + ( + [], + {}, + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"content": "Soda"}, + {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, + ), + ( + [], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["summary", "due_date", "due_datetime", "description"], +) async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + item_data: dict[str, Any], + tasks_after_update: list[Task], + add_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for adding a To-do Item.""" @@ -71,28 +167,35 @@ async def test_add_todo_list_item( api.add_task = AsyncMock() # Fake API response when state is refreshed after create - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=False) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "add_item", - {"item": "Soda"}, + {"item": "Soda", **item_data}, target={"entity_id": "todo.name"}, blocking=True, ) args = api.add_task.call_args assert args - assert args.kwargs.get("content") == "Soda" - assert args.kwargs.get("project_id") == PROJECT_ID + assert args.kwargs == {"project_id": PROJECT_ID, "content": "Soda", **add_kwargs} # Verify state is refreshed state = hass.states.get("todo.name") assert state assert state.state == "1" + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} + @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] @@ -158,12 +261,91 @@ async def test_update_todo_item_status( @pytest.mark.parametrize( - ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] + ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), + [ + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"rename": "Milk"}, + [make_api_task(id="task-id-1", content="Milk", is_completed=False)], + {"task_id": "task-id-1", "content": "Milk"}, + {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_date": "2023-11-18"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due(is_recurring=False, date="2023-11-18", string="today"), + ) + ], + {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"due_datetime": "2023-11-18T06:30:00"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + due=Due( + date="2023-11-18", + is_recurring=False, + datetime="2023-11-18T12:30:00.000000Z", + string="today", + ), + ) + ], + { + "task_id": "task-id-1", + "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "due": "2023-11-18T06:30:00-06:00", + }, + ), + ( + [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + {"description": "6-pack"}, + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"task_id": "task-id-1", "description": "6-pack"}, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + "description": "6-pack", + }, + ), + ], + ids=["rename", "due_date", "due_datetime", "description"], ) -async def test_update_todo_item_summary( +async def test_update_todo_items( hass: HomeAssistant, setup_integration: None, api: AsyncMock, + update_data: dict[str, Any], + tasks_after_update: list[Task], + update_kwargs: dict[str, Any], + expected_item: dict[str, Any], ) -> None: """Test for updating a To-do Item that changes the summary.""" @@ -174,22 +356,29 @@ async def test_update_todo_item_summary( api.update_task = AsyncMock() # Fake API response when state is refreshed after close - api.get_tasks.return_value = [ - make_api_task(id="task-id-1", content="Soda", is_completed=True) - ] + api.get_tasks.return_value = tasks_after_update await hass.services.async_call( TODO_DOMAIN, "update_item", - {"item": "task-id-1", "rename": "Milk"}, + {"item": "task-id-1", **update_data}, target={"entity_id": "todo.name"}, blocking=True, ) assert api.update_task.called args = api.update_task.call_args assert args - assert args.kwargs.get("task_id") == "task-id-1" - assert args.kwargs.get("content") == "Milk" + assert args.kwargs == update_kwargs + + result = await hass.services.async_call( + TODO_DOMAIN, + "get_items", + {}, + target={"entity_id": "todo.name"}, + blocking=True, + return_response=True, + ) + assert result == {"todo.name": {"items": [expected_item]}} @pytest.mark.parametrize( diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py index 026c122fb57..a9aa3ad70d1 100644 --- a/tests/components/trafikverket_camera/__init__.py +++ b/tests/components/trafikverket_camera/__init__.py @@ -2,9 +2,14 @@ from __future__ import annotations from homeassistant.components.trafikverket_camera.const import CONF_LOCATION -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_ID: "1234", +} + +ENTRY_CONFIG_OLD_CONFIG = { CONF_API_KEY: "1234567890", CONF_LOCATION: "Test location", } diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index a4902ac2950..a5eeb707b34 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -32,9 +32,9 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) config_entry.add_to_hass(hass) @@ -54,7 +54,7 @@ def fixture_get_camera() -> CameraInfo: """Construct Camera Mock.""" return CameraInfo( - camera_name="Test_camera", + camera_name="Test Camera", camera_id="1234", active=True, deleted=False, diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py index 6f7eb540289..87d0e6d58b7 100644 --- a/tests/components/trafikverket_camera/test_binary_sensor.py +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -16,5 +16,5 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera binary sensor.""" - state = hass.states.get("binary_sensor.test_location_active") + state = hass.states.get("binary_sensor.test_camera_active") assert state.state == STATE_ON diff --git a/tests/components/trafikverket_camera/test_camera.py b/tests/components/trafikverket_camera/test_camera.py index b3df7cfcdcb..182924e9f0e 100644 --- a/tests/components/trafikverket_camera/test_camera.py +++ b/tests/components/trafikverket_camera/test_camera.py @@ -26,7 +26,7 @@ async def test_camera( get_camera: CameraInfo, ) -> None: """Test the Trafikverket Camera sensor.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -44,11 +44,11 @@ async def test_camera( async_fire_time_changed(hass) await hass.async_block_till_done() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes != {} - assert await async_get_image(hass, "camera.test_location") + assert await async_get_image(hass, "camera.test_camera") monkeypatch.setattr( get_camera, @@ -69,4 +69,4 @@ async def test_camera( await hass.async_block_till_done() with pytest.raises(HomeAssistantError): - await async_get_image(hass, "camera.test_location") + await async_get_image(hass, "camera.test_camera") diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index b53763c0ac7..305066832e5 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -14,7 +14,7 @@ from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -47,10 +47,10 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Test location" + assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test location", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -87,7 +87,7 @@ async def test_form_no_location_data( assert result2["title"] == "Test Camera" assert result2["data"] == { "api_key": "1234567890", - "location": "Test Camera", + "id": "1234", } assert len(mock_setup_entry.mock_calls) == 1 assert result2["result"].unique_id == "trafikverket_camera-1234" @@ -150,10 +150,10 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) @@ -186,7 +186,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } @@ -223,10 +223,10 @@ async def test_reauth_flow_error( domain=DOMAIN, data={ CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_ID: "1234", }, unique_id="1234", - version=2, + version=3, ) entry.add_to_hass(hass) await hass.async_block_till_done() @@ -271,5 +271,5 @@ async def test_reauth_flow_error( assert result2["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", - "location": "Test location", + "id": "1234", } diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index 4183aa9fffa..0f79307e0b6 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -40,9 +40,9 @@ async def test_coordinator( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -54,7 +54,7 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" @@ -101,9 +101,9 @@ async def test_coordinator_failed_update( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -115,7 +115,7 @@ async def test_coordinator_failed_update( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state == entry_state @@ -135,7 +135,7 @@ async def test_coordinator_failed_get_image( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", title="Test location", ) @@ -149,6 +149,6 @@ async def test_coordinator_failed_get_image( await hass.async_block_till_done() mock_data.assert_called_once() - state = hass.states.get("camera.test_location") + state = hass.states.get("camera.test_camera") assert state is None assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index 83a3fc1486a..e10c6c16e33 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime from unittest.mock import patch +import pytest from pytrafikverket.exceptions import UnknownError from pytrafikverket.trafikverket_camera import CameraInfo @@ -14,7 +15,7 @@ from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import ENTRY_CONFIG +from . import ENTRY_CONFIG, ENTRY_CONFIG_OLD_CONFIG from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -35,9 +36,9 @@ async def test_setup_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -67,9 +68,9 @@ async def test_unload_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - version=2, + version=3, unique_id="trafikverket_camera-1234", - title="Test location", + title="Test Camera", ) entry.add_to_hass(hass) @@ -99,7 +100,7 @@ async def test_migrate_entry( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", unique_id="trafikverket_camera-Test location", title="Test location", @@ -114,15 +115,31 @@ async def test_migrate_entry( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED - assert entry.version == 2 + assert entry.version == 3 assert entry.unique_id == "trafikverket_camera-1234" - assert len(mock_tvt_camera.mock_calls) == 2 + assert entry.data == ENTRY_CONFIG + assert len(mock_tvt_camera.mock_calls) == 3 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_with_error( hass: HomeAssistant, get_camera: CameraInfo, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails with api error.""" aioclient_mock.get( @@ -132,9 +149,10 @@ async def test_migrate_entry_fails_with_error( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -147,14 +165,29 @@ async def test_migrate_entry_fails_with_error( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 +@pytest.mark.parametrize( + ("version", "unique_id"), + [ + ( + 1, + "trafikverket_camera-Test location", + ), + ( + 2, + "trafikverket_camera-1234", + ), + ], +) async def test_migrate_entry_fails_no_id( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + version: int, + unique_id: str, ) -> None: """Test migrate entry fails, camera returns no id.""" aioclient_mock.get( @@ -164,9 +197,10 @@ async def test_migrate_entry_fails_no_id( entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, - data=ENTRY_CONFIG, + data=ENTRY_CONFIG_OLD_CONFIG, entry_id="1", - unique_id="trafikverket_camera-Test location", + version=version, + unique_id=unique_id, title="Test location", ) entry.add_to_hass(hass) @@ -195,8 +229,8 @@ async def test_migrate_entry_fails_no_id( await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR - assert entry.version == 1 - assert entry.unique_id == "trafikverket_camera-Test location" + assert entry.version == version + assert entry.unique_id == unique_id assert len(mock_tvt_camera.mock_calls) == 1 @@ -214,7 +248,7 @@ async def test_no_migration_needed( domain=DOMAIN, source=SOURCE_USER, data=ENTRY_CONFIG, - version=2, + version=3, entry_id="1234", unique_id="trafikverket_camera-1234", title="Test location", diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index b9add7ae483..777c6ea26b3 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -24,7 +24,7 @@ async def test_exclude_attributes( get_camera: CameraInfo, ) -> None: """Test camera has description and location excluded from recording.""" - state1 = hass.states.get("camera.test_location") + state1 = hass.states.get("camera.test_camera") assert state1.state == "idle" assert state1.attributes["description"] == "Test Camera for testing" assert state1.attributes["location"] == "Test location" @@ -39,10 +39,10 @@ async def test_exclude_attributes( hass.states.async_entity_ids(), ) assert len(states) == 8 - assert states.get("camera.test_location") + assert states.get("camera.test_camera") for entity_states in states.values(): for state in entity_states: - if state.entity_id == "camera.test_location": + if state.entity_id == "camera.test_camera": assert "location" not in state.attributes assert "description" not in state.attributes assert "type" in state.attributes diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index 581fed1d289..c1c98aed797 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -15,15 +15,15 @@ async def test_sensor( ) -> None: """Test the Trafikverket Camera sensor.""" - state = hass.states.get("sensor.test_location_direction") + state = hass.states.get("sensor.test_camera_direction") assert state.state == "180" - state = hass.states.get("sensor.test_location_modified") + state = hass.states.get("sensor.test_camera_modified") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_time") + state = hass.states.get("sensor.test_camera_photo_time") assert state.state == "2022-04-04T04:04:04+00:00" - state = hass.states.get("sensor.test_location_photo_url") + state = hass.states.get("sensor.test_camera_photo_url") assert state.state == "https://www.testurl.com/test_photo.jpg" - state = hass.states.get("sensor.test_location_status") + state = hass.states.get("sensor.test_camera_status") assert state.state == "Running" - state = hass.states.get("sensor.test_location_camera_type") + state = hass.states.get("sensor.test_camera_camera_type") assert state.state == "Road" diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index cccf1add61b..b525c7a8fa3 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,385 +1,247 @@ """The test for the Trend sensor platform.""" from datetime import timedelta +import logging +from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config as hass_config, setup from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN +from homeassistant.const import SERVICE_RELOAD, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_fixture_path, - get_test_home_assistant, - mock_restore_cache, +from tests.common import assert_setup_component, get_fixture_path, mock_restore_cache + + +async def _setup_component(hass: HomeAssistant, params: dict[str, Any]) -> None: + """Set up the trend component.""" + assert await async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": params, + }, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], ) +async def test_basic_trend( + hass: HomeAssistant, + states: list[str], + inverted: bool, + expected_state: str, +): + """Test trend with a basic setup.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "invert": inverted, + }, + ) + + for state in states: + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state -class TestTrendBinarySensor: - """Test the Trend sensor.""" +@pytest.mark.parametrize( + ("state_series", "inverted", "expected_states"), + [ + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + False, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + True, + [STATE_UNKNOWN, STATE_OFF, STATE_ON], + ), + ( + [[30, 20, 30, 10], [5], [30, 0, 45, 60]], + True, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ], + ids=["up", "up inverted", "down"], +) +async def test_using_trendline( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + state_series: list[list[str]], + inverted: bool, + expected_states: list[str], +): + """Test uptrend using multiple samples and trendline calculation.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + "invert": inverted, + }, + ) - hass = None + for idx, states in enumerate(state_series): + for state in states: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_states[idx] - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - def test_up(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, +@pytest.mark.parametrize( + ("attr_values", "expected_state"), + [ + (["1", "2"], STATE_ON), + (["2", "1"], STATE_OFF), + ], + ids=["up", "down"], +) +async def test_attribute_trend( + hass: HomeAssistant, + attr_values: list[str], + expected_state: str, +): + """Test attribute uptrend.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "attr", + }, + ) + + for attr in attr_values: + hass.states.async_set("sensor.test_state", "State", {"attr": attr}) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +async def test_max_samples(hass: HomeAssistant): + """Test that sample count is limited correctly.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "max_samples": 3, + "min_gradient": -1, + }, + ) + + for val in [0, 1, 2, 3, 2, 1]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == "on" + assert state.attributes["sample_count"] == 3 + + +async def test_non_numeric(hass: HomeAssistant): + """Test for non-numeric sensor.""" + await _setup_component(hass, {"entity_id": "sensor.test_state"}) + + hass.states.async_set("sensor.test_state", "Non") + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "Numeric") + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_missing_attribute(hass: HomeAssistant): + """Test for missing attribute.""" + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "attribute": "missing", + }, + ) + + hass.states.async_set("sensor.test_state", "State", {"attr": "2"}) + await hass.async_block_till_done() + hass.states.async_set("sensor.test_state", "State", {"attr": "1"}) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_invalid_name_does_not_create(hass: HomeAssistant): + """Test for invalid name.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} + "test INVALID sensor": {"entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - def test_up_using_trendline(self): - """Test up trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, +async def test_invalid_sensor_does_not_create(hass: HomeAssistant): + """Test invalid sensor.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", { "binary_sensor": { "platform": "trend", "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - } + "test_trend_sensor": {"not_entity_id": "sensor.test_state"} }, } }, ) - self.hass.block_till_done() + assert hass.states.async_all("binary_sensor") == [] - now = dt_util.utcnow() - for val in [10, 0, 20, 30]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - # have to change state value, otherwise sample will lost - for val in [0, 30, 1, 0]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down_using_trendline(self): - """Test down trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "invert": "Yes", - } - }, - } - }, +async def test_no_sensors_does_not_create(hass: HomeAssistant): + """Test no sensors.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [30, 20, 30, 10]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - for val in [30, 0, 45, 50]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down(self): - """Test down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_up(self): - """Test up trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_down(self): - """Test down trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_up(self): - """Test attribute up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_down(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_max_samples(self): - """Test that sample count is limited correctly.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 3, - "min_gradient": -1, - } - }, - } - }, - ) - self.hass.block_till_done() - - for val in [0, 1, 2, 3, 2, 1]: - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - assert state.attributes["sample_count"] == 3 - - def test_non_numeric(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "Non") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "Numeric") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_missing_attribute(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "missing", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_invalid_name_does_not_create(self): - """Test invalid name.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_invalid_sensor_does_not_create(self): - """Test invalid sensor.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_trend_sensor": {"not_entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_no_sensors_does_not_create(self): - """Test no sensors.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} - ) - assert self.hass.states.all("binary_sensor") == [] + assert hass.states.async_all("binary_sensor") == [] async def test_reload(hass: HomeAssistant) -> None: @@ -423,21 +285,65 @@ async def test_reload(hass: HomeAssistant) -> None: [("on", "on"), ("off", "off"), ("unknown", "unknown")], ) async def test_restore_state( - hass: HomeAssistant, saved_state: str, restored_state: str + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + saved_state: str, + restored_state: str, ) -> None: """Test we restore the trend state.""" mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) - assert await setup.async_setup_component( + await _setup_component( hass, - "binary_sensor", { - "binary_sensor": { - "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, - } + "entity_id": "sensor.test_state", + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, }, ) - await hass.async_block_till_done() + # restored sensor should match saved one assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add not enough samples to trigger calculation + for val in [10, 20, 30, 40]: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + # state should match restored state as no calculation happened + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add more samples to trigger calculation + for val in [50, 60, 70, 80]: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + # sensor should detect an upwards trend and turn on + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + +async def test_invalid_min_sample( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if error is logged when min_sample is larger than max_samples.""" + with caplog.at_level(logging.ERROR): + await _setup_component( + hass, + { + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, + }, + ) + + record = caplog.records[0] + assert record.levelname == "ERROR" + assert ( + "Invalid config for 'binary_sensor' from integration 'trend': min_samples must " + "be smaller than or equal to max_samples" in record.message + ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 71be6b3bb11..5be56edbc32 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -150,7 +150,7 @@ async def test_restore_state( async def test_setup_component(hass: HomeAssistant, setup: str) -> None: """Set up a TTS platform with defaults.""" assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.test" in hass.config.components + assert f"test.{tts.DOMAIN}" in hass.config.components @pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index abe12a1e243..5a12b99d10b 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,9 +1,8 @@ """The tests for the UniFi Network device tracker platform.""" from datetime import timedelta -from unittest.mock import patch from aiounifi.models.message import MessageKey -from freezegun.api import FrozenDateTimeFactory +from freezegun.api import FrozenDateTimeFactory, freeze_time from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -72,7 +71,7 @@ async def test_tracked_wireless_clients( # Change time to mark client as away new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -293,6 +292,7 @@ async def test_tracked_wireless_clients_event_source( async def test_tracked_devices( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, mock_device_registry, ) -> None: @@ -351,9 +351,9 @@ async def test_tracked_devices( # Change of time can mark device not_home outside of expected reporting interval new_time = dt_util.utcnow() + timedelta(seconds=90) - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device_2").state == STATE_HOME @@ -712,7 +712,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -740,7 +740,7 @@ async def test_option_ssid_filter( # Time pass to mark client as away new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -759,7 +759,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -808,7 +808,7 @@ async def test_wireless_client_go_wired_issue( # Pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -877,7 +877,7 @@ async def test_option_ignore_wired_bug( # pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index f4366b98fc3..854d136f3dd 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from unittest.mock import patch from aiounifi.models.message import MessageKey +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -429,6 +430,7 @@ async def test_bandwidth_sensors( async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, entity_registry_enabled_by_default: None, initial_uptime, @@ -450,13 +452,13 @@ async def test_uptime_sensors( } now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.now", return_value=now): - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[uptime_client], - ) + freezer.move_to(now) + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options=options, + clients_response=[uptime_client], + ) assert len(hass.states.async_all()) == 2 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 5c8d8d4253c..0ac8140c52d 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,8 +2,8 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.select import ( @@ -95,7 +95,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -116,7 +116,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -144,7 +144,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, @@ -221,7 +221,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -242,7 +242,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -270,7 +270,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2c64338c4f3..d77c2db356a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1,6 +1,5 @@ """The tests for the utility_meter sensor platform.""" from datetime import timedelta -from unittest.mock import patch from freezegun import freeze_time import pytest @@ -132,7 +131,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -166,7 +165,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, @@ -729,7 +728,7 @@ async def test_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -803,7 +802,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -813,7 +812,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, None, @@ -1148,7 +1147,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -1186,7 +1185,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get("status") == COLLECTING now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, diff --git a/tests/components/vallox/test_fan.py b/tests/components/vallox/test_fan.py index eb60a3d025d..12b24f46aba 100644 --- a/tests/components/vallox/test_fan.py +++ b/tests/components/vallox/test_fan.py @@ -10,6 +10,7 @@ from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, + NotValidPresetModeError, ) from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant @@ -179,7 +180,7 @@ async def test_set_invalid_preset_mode( """Test set preset mode.""" await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -189,6 +190,7 @@ async def test_set_invalid_preset_mode( }, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" async def test_set_preset_mode_exception( diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index f82a00087c6..dbb848f3b9d 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -1,7 +1,9 @@ """Test VoIP protocol.""" import asyncio +import io import time from unittest.mock import AsyncMock, Mock, patch +import wave import pytest @@ -14,6 +16,24 @@ _ONE_SECOND = 16000 * 2 # 16Khz 16-bit _MEDIA_ID = "12345" +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +def _empty_wav() -> bytes: + """Return bytes of an empty WAV file.""" + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + return wav_io.getvalue() + + async def test_pipeline( hass: HomeAssistant, voip_device: VoIPDevice, @@ -72,8 +92,7 @@ async def test_pipeline( media_source_id: str, ) -> tuple[str, bytes]: assert media_source_id == _MEDIA_ID - - return ("mp3", b"") + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -266,7 +285,7 @@ async def test_tts_timeout( media_source_id: str, ) -> tuple[str, bytes]: # Should time out immediately - return ("raw", bytes(0)) + return ("wav", _empty_wav()) with patch( "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", @@ -305,8 +324,8 @@ async def test_tts_timeout( done.set() - rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) - rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) + rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] # silence rtp_protocol.on_chunk(bytes(_ONE_SECOND)) @@ -320,3 +339,264 @@ async def test_tts_timeout( # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): await done.wait() + + +async def test_tts_wrong_extension( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not "wav" + return ("mp3", b"") + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_tts_wrong_wav_format( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will only stream WAV audio with a specific format.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Proceed with media output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {"media_id": _MEDIA_ID}}, + ) + ) + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + # Should fail because it's not 16Khz, 16-bit mono + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(2) + + return ("wav", wav_io.getvalue()) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ): + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + original_send_tts = rtp_protocol._send_tts + + async def send_tts(*args, **kwargs): + # Call original then end test successfully + with pytest.raises(ValueError): + await original_send_tts(*args, **kwargs) + + done.set() + + rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to exhaust the audio stream + async with asyncio.timeout(1): + await done.wait() + + +async def test_empty_tts_output( + hass: HomeAssistant, + voip_device: VoIPDevice, +) -> None: + """Test that TTS will not stream when output is empty.""" + assert await async_setup_component(hass, "voip", {}) + + def is_speech(self, chunk): + """Anything non-zero is speech.""" + return sum(chunk) > 0 + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + event_callback = kwargs["event_callback"] + async for _chunk in stt_stream: + # Stream will end when VAD detects end of "speech" + pass + + # Fake intent result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_END, + data={ + "intent_output": { + "conversation_id": "fake-conversation", + } + }, + ) + ) + + # Empty TTS output + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_END, + data={"tts_output": {}}, + ) + ) + + with patch( + "homeassistant.components.assist_pipeline.vad.WebRtcVad.is_speech", + new=is_speech, + ), patch( + "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch( + "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._send_tts", + ) as mock_send_tts: + rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( + hass, + hass.config.language, + voip_device, + Context(), + opus_payload_type=123, + ) + rtp_protocol.transport = Mock() + + # silence + rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + + # "speech" + rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + + # silence (assumes relaxed VAD sensitivity) + rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + + # Wait for mock pipeline to finish + async with asyncio.timeout(1): + await rtp_protocol._tts_done.wait() + + mock_send_tts.assert_not_called() diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 3890d6a28d1..b982ab610ec 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1286,7 +1286,7 @@ async def test_issue_forecast_deprecated_no_logging( assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test" in caplog.text + assert "Setting up test.weather" in caplog.text assert ( "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" not in caplog.text diff --git a/tests/components/weather/test_significant_change.py b/tests/components/weather/test_significant_change.py new file mode 100644 index 00000000000..93e5830a0ac --- /dev/null +++ b/tests/components/weather/test_significant_change.py @@ -0,0 +1,347 @@ +"""Test the Weather significant change platform.""" + +import pytest + +from homeassistant.components.weather.const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) +from homeassistant.components.weather.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature + + +async def test_significant_state_change() -> None: + """Detect Weather significant state changes.""" + assert not async_check_significant_change( + None, "clear-night", {}, "clear-night", {} + ) + assert async_check_significant_change(None, "clear-night", {}, "cloudy", {}) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # insignificant attributes + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b"}, + False, + ), + ({ATTR_WEATHER_PRESSURE_UNIT: "a"}, {ATTR_WEATHER_PRESSURE_UNIT: "b"}, False), + ( + {ATTR_WEATHER_TEMPERATURE_UNIT: "a"}, + {ATTR_WEATHER_TEMPERATURE_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_VISIBILITY_UNIT: "a"}, + {ATTR_WEATHER_VISIBILITY_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_WIND_SPEED_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + False, + ), + # significant attributes, close to but not significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 68.9, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + False, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.4}, + False, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 80.9}, + False, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "W"}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.09}, + False, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 20.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1000.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 750.06}, + { + ATTR_WEATHER_PRESSURE: 750.74, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.54, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + False, + ), + # significant attributes with significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 69, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + True, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.5}, + True, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 81}, + True, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "NW"}, # NW = 315° + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.1}, + True, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 21}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1001}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 750}, + { + ATTR_WEATHER_PRESSURE: 749, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.55, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # invalid new values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + False, + ), + # invalid old values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + True, + ), + ], +) +async def test_invalid_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather invalid attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py index f619ace237a..7113e1d4d51 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -23,7 +23,7 @@ async def test_failed_updates(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - utcnow() + timedelta(minutes=15), + utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index 9cbf8768dd5..74573e2185b 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -60,7 +60,7 @@ async def test_webostv_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index a9551310c2a..127b45484be 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2317,6 +2317,65 @@ async def test_execute_script( assert call.context.as_dict() == msg_var["result"]["context"] +@pytest.mark.parametrize( + ("raise_exception", "err_code"), + [ + ( + HomeAssistantError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "home_assistant_error", + ), + ( + ServiceValidationError( + "Some error", + translation_domain="test", + translation_key="test_error", + translation_placeholders={"option": "bla"}, + ), + "service_validation_error", + ), + ], +) +async def test_execute_script_err_localization( + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + raise_exception: HomeAssistantError, + err_code: str, +) -> None: + """Test testing a condition.""" + async_mock_service( + hass, "domain_test", "test_service", raise_exception=raise_exception + ) + + await websocket_client.send_json( + { + "id": 5, + "type": "execute_script", + "sequence": [ + { + "service": "domain_test.test_service", + "data": {"hello": "world"}, + }, + {"stop": "done", "response_variable": "service_result"}, + ], + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + assert msg["error"]["code"] == err_code + assert msg["error"]["message"] == "Some error" + assert msg["error"]["translation_key"] == "test_error" + assert msg["error"]["translation_domain"] == "test" + assert msg["error"]["translation_placeholders"] == {"option": "bla"} + + async def test_execute_script_complex_response( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index da435d64d58..80936d30752 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -39,9 +39,15 @@ from tests.common import MockUser ), ( exceptions.HomeAssistantError("Failed to do X"), - websocket_api.ERR_UNKNOWN_ERROR, + websocket_api.ERR_HOME_ASSISTANT_ERROR, "Failed to do X", - "Error handling message: Failed to do X (unknown_error) Mock User from 127.0.0.42 (Browser)", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", + ), + ( + exceptions.ServiceValidationError("Failed to do X"), + websocket_api.ERR_HOME_ASSISTANT_ERROR, + "Failed to do X", + "Error handling message: Failed to do X (home_assistant_error) Mock User from 127.0.0.42 (Browser)", ), ( ValueError("Really bad"), diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 7457d2e0ada..a359d83d87d 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -316,6 +316,18 @@ async def test_check_date_service( ) assert response == {"binary_sensor.workday_sensor": {"workday": True}} + response = await hass.services.async_call( + DOMAIN, + SERVICE_CHECK_DATE, + { + "entity_id": "binary_sensor.workday_sensor", + "check_date": date(2022, 12, 17), # Saturday (no workday) + }, + blocking=True, + return_response=True, + ) + assert response == {"binary_sensor.workday_sensor": {"workday": False}} + async def test_language_difference_english_language( hass: HomeAssistant, diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index e04ff4eda03..268ebef1d06 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,11 +1,14 @@ """Tests for the Wyoming integration.""" import asyncio +from unittest.mock import patch +from wyoming.event import Event from wyoming.info import ( AsrModel, AsrProgram, Attribution, Info, + Satellite, TtsProgram, TtsVoice, TtsVoiceSpeaker, @@ -13,6 +16,10 @@ from wyoming.info import ( WakeProgram, ) +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.core import HomeAssistant + TEST_ATTR = Attribution(name="Test", url="http://www.test.com") STT_INFO = Info( asr=[ @@ -72,24 +79,36 @@ WAKE_WORD_INFO = Info( ) ] ) +SATELLITE_INFO = Info( + satellite=Satellite( + name="Test Satellite", + description="Test Satellite", + installed=True, + attribution=TEST_ATTR, + area="Office", + ) +) EMPTY_INFO = Info() class MockAsyncTcpClient: """Mock AsyncTcpClient.""" - def __init__(self, responses) -> None: + def __init__(self, responses: list[Event]) -> None: """Initialize.""" - self.host = None - self.port = None - self.written = [] + self.host: str | None = None + self.port: int | None = None + self.written: list[Event] = [] self.responses = responses - async def write_event(self, event): + async def connect(self) -> None: + """Connect.""" + + async def write_event(self, event: Event): """Send.""" self.written.append(event) - async def read_event(self): + async def read_event(self) -> Event | None: """Receive.""" await asyncio.sleep(0) # force context switch @@ -105,8 +124,24 @@ class MockAsyncTcpClient: async def __aexit__(self, exc_type, exc, tb): """Exit.""" - def __call__(self, host, port): + def __call__(self, host: str, port: int): """Call.""" self.host = host self.port = port return self + + +async def reload_satellite( + hass: HomeAssistant, config_entry_id: str +) -> SatelliteDevice: + """Reload config entry with satellite info and returns new device.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_reload(config_entry_id) + + return hass.data[DOMAIN][config_entry_id].satellite.device diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 2c8081908f7..f22ec7e9e16 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -5,14 +5,29 @@ from unittest.mock import AsyncMock, patch import pytest from homeassistant.components import stt +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -from . import STT_INFO, TTS_INFO, WAKE_WORD_INFO +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +@pytest.fixture(autouse=True) +async def init_components(hass: HomeAssistant): + """Set up required components.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" @@ -110,3 +125,39 @@ def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ) + + +@pytest.fixture +def satellite_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_satellite(hass: HomeAssistant, satellite_config_entry: ConfigEntry): + """Initialize Wyoming satellite.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_setup(satellite_config_entry.entry_id) + + +@pytest.fixture +async def satellite_device( + hass: HomeAssistant, init_satellite, satellite_config_entry: ConfigEntry +) -> SatelliteDevice: + """Get a satellite device fixture.""" + return hass.data[DOMAIN][satellite_config_entry.entry_id].satellite.device diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index d4220a39724..99f411027f5 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -121,3 +121,45 @@ 'version': 1, }) # --- +# name: test_zeroconf_discovery + FlowResultSnapshot({ + 'context': dict({ + 'name': 'Test Satellite', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Test Satellite', + }), + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'wyoming', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'port': 12345, + }), + 'disabled_by': None, + 'domain': 'wyoming', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Test Satellite', + 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', + 'version': 1, + }), + 'title': 'Test Satellite', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/wyoming/snapshots/test_stt.ambr b/tests/components/wyoming/snapshots/test_stt.ambr index 784f89b2ab8..b45b7508b28 100644 --- a/tests/components/wyoming/snapshots/test_stt.ambr +++ b/tests/components/wyoming/snapshots/test_stt.ambr @@ -6,7 +6,7 @@ 'language': 'en', }), 'payload': None, - 'type': 'transcibe', + 'type': 'transcribe', }), dict({ 'data': dict({ diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py new file mode 100644 index 00000000000..fba181a63ca --- /dev/null +++ b/tests/components/wyoming/test_binary_sensor.py @@ -0,0 +1,37 @@ +"""Test Wyoming binary sensor devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_assist_in_progress( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test assist in progress.""" + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active + + satellite_device.set_is_active(True) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_active + + # test restore does *not* happen + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(assist_in_progress_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_active diff --git a/tests/components/wyoming/test_config_flow.py b/tests/components/wyoming/test_config_flow.py index 896d3748ebd..f711b56b3bc 100644 --- a/tests/components/wyoming/test_config_flow.py +++ b/tests/components/wyoming/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Wyoming config flow.""" +from ipaddress import IPv4Address from unittest.mock import AsyncMock, patch import pytest @@ -8,10 +9,11 @@ from wyoming.info import Info from homeassistant import config_entries from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.wyoming.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import EMPTY_INFO, STT_INFO, TTS_INFO +from . import EMPTY_INFO, SATELLITE_INFO, STT_INFO, TTS_INFO from tests.common import MockConfigEntry @@ -25,6 +27,16 @@ ADDON_DISCOVERY = HassioServiceInfo( uuid="1234", ) +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=IPv4Address("127.0.0.1"), + ip_addresses=[IPv4Address("127.0.0.1")], + port=12345, + hostname="localhost", + type="_wyoming._tcp.local.", + name="test_zeroconf_name._wyoming._tcp.local.", + properties={}, +) + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -214,3 +226,70 @@ async def test_hassio_addon_no_supported_services(hass: HomeAssistant) -> None: assert result2.get("type") == FlowResultType.ABORT assert result2.get("reason") == "no_services" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config flow initiated by Supervisor.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("description_placeholders") == { + "name": SATELLITE_INFO.satellite.name + } + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +async def test_zeroconf_discovery_no_port( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when the zeroconf service does not have a port.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch.object(ZEROCONF_DISCOVERY, "port", None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_port" + + +async def test_zeroconf_discovery_no_services( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test discovery when there are no supported services on the client.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=Info(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZEROCONF_DISCOVERY, + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "no_services" diff --git a/tests/components/wyoming/test_data.py b/tests/components/wyoming/test_data.py index 0cb878c39c1..b7de9dbfdc1 100644 --- a/tests/components/wyoming/test_data.py +++ b/tests/components/wyoming/test_data.py @@ -3,13 +3,15 @@ from __future__ import annotations from unittest.mock import patch -from homeassistant.components.wyoming.data import load_wyoming_info +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.wyoming.data import WyomingService, load_wyoming_info from homeassistant.core import HomeAssistant -from . import STT_INFO, MockAsyncTcpClient +from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO, MockAsyncTcpClient -async def test_load_info(hass: HomeAssistant, snapshot) -> None: +async def test_load_info(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test loading info.""" with patch( "homeassistant.components.wyoming.data.AsyncTcpClient", @@ -38,3 +40,38 @@ async def test_load_info_oserror(hass: HomeAssistant) -> None: ) assert info is None + + +async def test_service_name(hass: HomeAssistant) -> None: + """Test loading service info.""" + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([STT_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == STT_INFO.asr[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([TTS_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == TTS_INFO.tts[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([WAKE_WORD_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == WAKE_WORD_INFO.wake[0].name + + with patch( + "homeassistant.components.wyoming.data.AsyncTcpClient", + MockAsyncTcpClient([SATELLITE_INFO.event()]), + ): + service = await WyomingService.create("localhost", 1234) + assert service is not None + assert service.get_name() == SATELLITE_INFO.satellite.name diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py new file mode 100644 index 00000000000..549f76f20f1 --- /dev/null +++ b/tests/components/wyoming/test_devices.py @@ -0,0 +1,78 @@ +"""Test Wyoming devices.""" +from __future__ import annotations + +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_device_registry_info( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + satellite_config_entry: ConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test info in device registry.""" + + # Satellite uses config entry id since only one satellite per entry is + # supported. + device = device_registry.async_get_device( + identifiers={(DOMAIN, satellite_config_entry.entry_id)} + ) + assert device is not None + assert device.name == "Test Satellite" + assert device.suggested_area == "Office" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assist_in_progress_state = hass.states.get(assist_in_progress_id) + assert assist_in_progress_state is not None + assert assist_in_progress_state.state == STATE_OFF + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + satellite_enabled_state = hass.states.get(satellite_enabled_id) + assert satellite_enabled_state is not None + assert satellite_enabled_state.state == STATE_ON + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + pipeline_state = hass.states.get(pipeline_entity_id) + assert pipeline_state is not None + assert pipeline_state.state == OPTION_PREFERRED + + +async def test_remove_device_registry_entry( + hass: HomeAssistant, + satellite_device: SatelliteDevice, + device_registry: dr.DeviceRegistry, +) -> None: + """Test removing a device registry entry.""" + + # Check associated entities + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + assert hass.states.get(assist_in_progress_id) is not None + + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + assert hass.states.get(satellite_enabled_id) is not None + + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + assert hass.states.get(pipeline_entity_id) is not None + + # Remove + device_registry.async_remove_device(satellite_device.device_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Everything should be gone + assert hass.states.get(assist_in_progress_id) is None + assert hass.states.get(satellite_enabled_id) is None + assert hass.states.get(pipeline_entity_id) is None diff --git a/tests/components/wyoming/test_number.py b/tests/components/wyoming/test_number.py new file mode 100644 index 00000000000..084021d61a7 --- /dev/null +++ b/tests/components/wyoming/test_number.py @@ -0,0 +1,102 @@ +"""Test Wyoming number.""" +from unittest.mock import patch + +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_auto_gain_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test automatic gain control number.""" + agc_entity_id = satellite_device.get_auto_gain_entity_id(hass) + assert agc_entity_id + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 0 + assert satellite_device.auto_gain == 0 + + # Change setting + with patch.object(satellite_device, "set_auto_gain") as mock_agc_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 31}, + blocking=True, + ) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + # set function should have been called + mock_agc_changed.assert_called_once_with(31) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 15}, + blocking=True, + ) + + assert satellite_device.auto_gain == 15 + + +async def test_volume_multiplier_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test volume multiplier number.""" + vm_entity_id = satellite_device.get_volume_multiplier_entity_id(hass) + assert vm_entity_id + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 1.0 + assert satellite_device.volume_multiplier == 1.0 + + # Change setting + with patch.object(satellite_device, "set_volume_multiplier") as mock_vm_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 2.0}, + blocking=True, + ) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + # set function should have been called + mock_vm_changed.assert_called_once_with(2.0) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 0.5}, + blocking=True, + ) + + assert float(satellite_device.volume_multiplier) == 0.5 diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py new file mode 100644 index 00000000000..50252007aa5 --- /dev/null +++ b/tests/components/wyoming/test_satellite.py @@ -0,0 +1,508 @@ +"""Test Wyoming satellite.""" +from __future__ import annotations + +import asyncio +import io +from unittest.mock import patch +import wave + +from wyoming.asr import Transcribe, Transcript +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.error import Error +from wyoming.event import Event +from wyoming.pipeline import PipelineStage, RunPipeline +from wyoming.satellite import RunSatellite +from wyoming.tts import Synthesize +from wyoming.vad import VoiceStarted, VoiceStopped +from wyoming.wake import Detect, Detection + +from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components.wyoming.data import WyomingService +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import SATELLITE_INFO, MockAsyncTcpClient + +from tests.common import MockConfigEntry + + +async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up config entry for Wyoming satellite. + + This is separated from the satellite_config_entry method in conftest.py so + we can patch functions before the satellite task is run during setup. + """ + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Satellite", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +def get_test_wav() -> bytes: + """Get bytes for test WAV file.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + + # Single frame + wav_file.writeframes(b"123") + + return wav_io.getvalue() + + +class SatelliteAsyncTcpClient(MockAsyncTcpClient): + """Satellite AsyncTcpClient.""" + + def __init__(self, responses: list[Event]) -> None: + """Initialize client.""" + super().__init__(responses) + + self.connect_event = asyncio.Event() + self.run_satellite_event = asyncio.Event() + self.detect_event = asyncio.Event() + + self.detection_event = asyncio.Event() + self.detection: Detection | None = None + + self.transcribe_event = asyncio.Event() + self.transcribe: Transcribe | None = None + + self.voice_started_event = asyncio.Event() + self.voice_started: VoiceStarted | None = None + + self.voice_stopped_event = asyncio.Event() + self.voice_stopped: VoiceStopped | None = None + + self.transcript_event = asyncio.Event() + self.transcript: Transcript | None = None + + self.synthesize_event = asyncio.Event() + self.synthesize: Synthesize | None = None + + self.tts_audio_start_event = asyncio.Event() + self.tts_audio_chunk_event = asyncio.Event() + self.tts_audio_stop_event = asyncio.Event() + self.tts_audio_chunk: AudioChunk | None = None + + self.error_event = asyncio.Event() + self.error: Error | None = None + + self._mic_audio_chunk = AudioChunk( + rate=16000, width=2, channels=1, audio=b"chunk" + ).event() + + async def connect(self) -> None: + """Connect.""" + self.connect_event.set() + + async def write_event(self, event: Event): + """Send.""" + if RunSatellite.is_type(event.type): + self.run_satellite_event.set() + elif Detect.is_type(event.type): + self.detect_event.set() + elif Detection.is_type(event.type): + self.detection = Detection.from_event(event) + self.detection_event.set() + elif Transcribe.is_type(event.type): + self.transcribe = Transcribe.from_event(event) + self.transcribe_event.set() + elif VoiceStarted.is_type(event.type): + self.voice_started = VoiceStarted.from_event(event) + self.voice_started_event.set() + elif VoiceStopped.is_type(event.type): + self.voice_stopped = VoiceStopped.from_event(event) + self.voice_stopped_event.set() + elif Transcript.is_type(event.type): + self.transcript = Transcript.from_event(event) + self.transcript_event.set() + elif Synthesize.is_type(event.type): + self.synthesize = Synthesize.from_event(event) + self.synthesize_event.set() + elif AudioStart.is_type(event.type): + self.tts_audio_start_event.set() + elif AudioChunk.is_type(event.type): + self.tts_audio_chunk = AudioChunk.from_event(event) + self.tts_audio_chunk_event.set() + elif AudioStop.is_type(event.type): + self.tts_audio_stop_event.set() + elif Error.is_type(event.type): + self.error = Error.from_event(event) + self.error_event.set() + + async def read_event(self) -> Event | None: + """Receive.""" + event = await super().read_event() + + # Keep sending audio chunks instead of None + return event or self._mic_audio_chunk + + +async def test_satellite_pipeline(hass: HomeAssistant) -> None: + """Test running a pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.tts.async_get_media_source_audio", + return_value=("wav", get_test_wav()), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + assert mock_run_pipeline.call_args.kwargs.get("device_id") == device.device_id + + # Start detecting wake word + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_START + ) + ) + async with asyncio.timeout(1): + await mock_client.detect_event.wait() + + assert not device.is_active + assert device.is_enabled + + # Wake word is detected + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.WAKE_WORD_END, + {"wake_word_output": {"wake_word_id": "test_wake_word"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.detection_event.wait() + + assert mock_client.detection is not None + assert mock_client.detection.name == "test_wake_word" + + # "Assist in progress" sensor should be active now + assert device.is_active + + # Speech-to-text started + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + assert mock_client.transcribe is not None + assert mock_client.transcribe.language == "en" + + # User started speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + assert mock_client.voice_started is not None + assert mock_client.voice_started.timestamp == 1234 + + # User stopped speaking + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + assert mock_client.voice_stopped is not None + assert mock_client.voice_stopped.timestamp == 5678 + + # Speech-to-text transcription + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + assert mock_client.transcript is not None + assert mock_client.transcript.text == "test transcript" + + # Text-to-speech text + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Text-to-speech media + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"media_id": "test media id"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"123" + + # Pipeline finished + event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + assert not device.is_active + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_satellite_disabled(hass: HomeAssistant) -> None: + """Test callback for a satellite that has been disabled.""" + on_disabled_event = asyncio.Event() + + original_make_satellite = wyoming._make_satellite + + def make_disabled_satellite( + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService + ): + satellite = original_make_satellite(hass, config_entry, service) + satellite.device.is_enabled = False + + return satellite + + async def on_disabled(self): + on_disabled_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming._make_satellite", make_disabled_satellite + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled", + on_disabled, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_disabled_event.wait() + + +async def test_satellite_restart(hass: HomeAssistant) -> None: + """Test pipeline loop restart after unexpected error.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite._run_once", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + +async def test_satellite_reconnect(hass: HomeAssistant) -> None: + """Test satellite reconnect call after connection refused.""" + on_reconnect_event = asyncio.Event() + + async def on_reconnect(self): + self.stop() + on_reconnect_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient.connect", + side_effect=ConnectionRefusedError(), + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect", + on_reconnect, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_reconnect_event.wait() + + +async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting before pipeline run.""" + on_restart_event = asyncio.Event() + + async def on_restart(self): + self.stop() + on_restart_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient([]), # no RunPipeline event + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ): + await setup_config_entry(hass) + async with asyncio.timeout(1): + await on_restart_event.wait() + + # Pipeline should never have run + mock_run_pipeline.assert_not_called() + + +async def test_satellite_disconnect_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite disconnecting during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + on_restart_event = asyncio.Event() + on_stopped_event = asyncio.Event() + + async def on_restart(self): + # Pretend sensor got stuck on + self.device.is_active = True + self.stop() + on_restart_event.set() + + async def on_stopped(self): + on_stopped_event.set() + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + MockAsyncTcpClient(events), + ), patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline, patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_restart", + on_restart, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped", + on_stopped, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await on_restart_event.wait() + await on_stopped_event.wait() + + # Pipeline should have run once + mock_run_pipeline.assert_called_once() + + # Sensor should have been turned off + assert not device.is_active + + +async def test_satellite_error_during_pipeline(hass: HomeAssistant) -> None: + """Test satellite error occurring during pipeline run.""" + events = [ + RunPipeline( + start_stage=PipelineStage.WAKE, end_stage=PipelineStage.TTS + ).event(), + ] # no audio chunks after RunPipeline + + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, patch( + "homeassistant.components.wyoming.satellite.assist_pipeline.async_pipeline_from_audio_stream", + ) as mock_run_pipeline: + await setup_config_entry(hass) + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + mock_run_pipeline.assert_called_once() + event_callback = mock_run_pipeline.call_args.kwargs["event_callback"] + event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.ERROR, + {"code": "test code", "message": "test message"}, + ) + ) + + async with asyncio.timeout(1): + await mock_client.error_event.wait() + + assert mock_client.error is not None + assert mock_client.error.text == "test message" + assert mock_client.error.code == "test code" diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py new file mode 100644 index 00000000000..128aab57a1a --- /dev/null +++ b/tests/components/wyoming/test_select.py @@ -0,0 +1,141 @@ +"""Test Wyoming select.""" +from unittest.mock import Mock, patch + +from homeassistant.components import assist_pipeline +from homeassistant.components.assist_pipeline.pipeline import PipelineData +from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import reload_satellite + + +async def test_pipeline_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test pipeline select. + + Functionality is tested in assist_pipeline/test_select.py. + This test is only to ensure it is set up. + """ + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + pipeline_data: PipelineData = hass.data[assist_pipeline.DOMAIN] + + # Create second pipeline + await pipeline_data.pipeline_store.async_create_item( + { + "name": "Test 1", + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + + # Preferred pipeline is the default + pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass) + assert pipeline_entity_id + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # Change to second pipeline + with patch.object(satellite_device, "set_pipeline_name") as mock_pipeline_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": "Test 1"}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + + # set function should have been called + mock_pipeline_changed.assert_called_once_with("Test 1") + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + + # Change back and check update listener + pipeline_listener = Mock() + satellite_device.set_pipeline_listener(pipeline_listener) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": pipeline_entity_id, "option": OPTION_PREFERRED}, + blocking=True, + ) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == OPTION_PREFERRED + + # listener should have been called + pipeline_listener.assert_called_once() + + +async def test_noise_suppression_level_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test noise suppression level select.""" + nsl_entity_id = satellite_device.get_noise_suppression_level_entity_id(hass) + assert nsl_entity_id + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "off" + assert satellite_device.noise_suppression_level == 0 + + # Change setting + with patch.object( + satellite_device, "set_noise_suppression_level" + ) as mock_nsl_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "max"}, + blocking=True, + ) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + # set function should have been called + mock_nsl_changed.assert_called_once_with(4) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "medium"}, + blocking=True, + ) + + assert satellite_device.noise_suppression_level == 2 diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py new file mode 100644 index 00000000000..a39b7087f6d --- /dev/null +++ b/tests/components/wyoming/test_switch.py @@ -0,0 +1,41 @@ +"""Test Wyoming switch devices.""" +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_satellite_enabled( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test satellite enabled.""" + satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass) + assert satellite_enabled_id + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_ON + assert satellite_device.is_enabled + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": satellite_enabled_id}, + blocking=True, + ) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF + assert not satellite_device.is_enabled + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(satellite_enabled_id) + assert state is not None + assert state.state == STATE_OFF diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 2f2a25558e4..301074e8ffb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -16,12 +16,6 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES from . import MockAsyncTcpClient -@pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): - """Mock the TTS cache dir with empty dir.""" - return mock_tts_cache_dir - - async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: """Test supported properties.""" state = hass.states.get("tts.test_tts") diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index af393339eba..6fc3259a4c0 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -16,6 +16,7 @@ CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} def _create_zone_mock(name, url): zone = MagicMock() zone.ctrl_url = url + zone.surround_programs = [] zone.zone = name return zone diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index a4ff5a3b205..1b3a536007a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -46,7 +46,7 @@ def disable_request_retry_delay(): with patch( "homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", zigpy.util.retryable_request(tries=3, delay=0), - ): + ), patch("homeassistant.components.zha.STARTUP_FAILURE_DELAY_S", 0.01): yield @@ -83,8 +83,8 @@ class _FakeApp(ControllerApplication): async def permit_ncp(self, time_s: int = 60): pass - async def permit_with_key( - self, node: zigpy.types.EUI64, code: bytes, time_s: int = 60 + async def permit_with_link_key( + self, node: zigpy.types.EUI64, link_key: zigpy.types.KeyData, time_s: int = 60 ): pass diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 9ec8048ea03..883df4aba94 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -10,7 +10,7 @@ import pytest import serial.tools.list_ports from zigpy.backups import BackupManager import zigpy.config -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, SCHEMA_DEVICE import zigpy.device from zigpy.exceptions import NetworkNotFormed import zigpy.types @@ -22,7 +22,7 @@ from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_ from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, - CONF_FLOWCONTROL, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN, EZSP_OVERWRITE_EUI64, @@ -118,9 +118,7 @@ def mock_detect_radio_type( async def detect(self): self.radio_type = radio_type - self.device_settings = radio_type.controller.SCHEMA_DEVICE( - {CONF_DEVICE_PATH: self.device_path} - ) + self.device_settings = SCHEMA_DEVICE({CONF_DEVICE_PATH: self.device_path}) return ret @@ -181,7 +179,7 @@ async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, CONF_DEVICE_PATH: "socket://192.168.1.200:6638", }, CONF_RADIO_TYPE: "znp", @@ -238,6 +236,8 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non assert result4["data"] == { CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "zigate", } @@ -287,7 +287,7 @@ async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.200:1234", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "ezsp", } @@ -304,7 +304,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://192.168.1.5:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -328,7 +328,7 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "socket://192.168.1.22:6638", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -483,6 +483,8 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None assert result4["data"] == { "device": { "path": "/dev/ttyZIGBEE", + "baudrate": 115200, + "flow_control": None, }, CONF_RADIO_TYPE: "zigate", } @@ -555,7 +557,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB1", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } }, ) @@ -579,7 +581,7 @@ async def test_discovery_via_usb_path_changes(hass: HomeAssistant) -> None: assert entry.data[CONF_DEVICE] == { CONF_DEVICE_PATH: "/dev/ttyZIGBEE", CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, } @@ -754,6 +756,8 @@ async def test_user_flow(hass: HomeAssistant) -> None: assert result2["data"] == { "device": { "path": port.device, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "deconz", } @@ -773,7 +777,11 @@ async def test_user_flow_not_detected(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, - data={zigpy.config.CONF_DEVICE_PATH: port_select}, + data={ + zigpy.config.CONF_DEVICE_PATH: port_select, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, ) assert result["type"] == FlowResultType.FORM @@ -951,31 +959,6 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: assert probe_mock.await_count == 1 -@pytest.mark.parametrize( - ("old_type", "new_type"), - [ - ("ezsp", "ezsp"), - ("ti_cc", "znp"), # only one that should change - ("znp", "znp"), - ("deconz", "deconz"), - ], -) -async def test_migration_ti_cc_to_znp( - old_type, new_type, hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test zigpy-cc to zigpy-znp config migration.""" - config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} - config_entry.version = 2 - config_entry.add_to_hass(hass) - - with patch("homeassistant.components.zha.async_setup_entry", return_value=True): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.version > 2 - assert config_entry.data[CONF_RADIO_TYPE] == new_type - - @pytest.mark.parametrize("onboarded", [True, False]) @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_hardware(onboarded, hass: HomeAssistant) -> None: @@ -1022,7 +1005,7 @@ async def test_hardware(onboarded, hass: HomeAssistant) -> None: assert result3["data"] == { CONF_DEVICE: { CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: "hardware", + CONF_FLOW_CONTROL: "hardware", CONF_DEVICE_PATH: "/dev/ttyAMA1", }, CONF_RADIO_TYPE: "ezsp", @@ -1171,6 +1154,7 @@ async def test_formation_strategy_form_initial_network( @patch(f"zigpy_znp.{PROBE_FUNCTION_PATH}", AsyncMock(return_value=True)) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) async def test_onboarding_auto_formation_new_hardware( mock_app, hass: HomeAssistant ) -> None: @@ -1577,7 +1561,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/ttyUSB0", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1645,7 +1629,7 @@ async def test_options_flow_defaults( # Change everything CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, ) @@ -1668,7 +1652,7 @@ async def test_options_flow_defaults( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/new_serial_port", CONF_BAUDRATE: 54321, - CONF_FLOWCONTROL: "software", + CONF_FLOW_CONTROL: "software", }, CONF_RADIO_TYPE: "znp", } @@ -1697,7 +1681,7 @@ async def test_options_flow_defaults_socket(hass: HomeAssistant) -> None: CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1766,7 +1750,7 @@ async def test_options_flow_restarts_running_zha_if_cancelled( CONF_DEVICE: { CONF_DEVICE_PATH: "socket://localhost:5678", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1821,7 +1805,7 @@ async def test_options_flow_migration_reset_old_adapter( CONF_DEVICE: { CONF_DEVICE_PATH: "/dev/serial/by-id/old_radio", CONF_BAUDRATE: 12345, - CONF_FLOWCONTROL: None, + CONF_FLOW_CONTROL: None, }, CONF_RADIO_TYPE: "znp", }, @@ -1954,3 +1938,28 @@ async def test_discovery_wrong_firmware_installed(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_firmware_installed" + + +@pytest.mark.parametrize( + ("old_type", "new_type"), + [ + ("ezsp", "ezsp"), + ("ti_cc", "znp"), # only one that should change + ("znp", "znp"), + ("deconz", "deconz"), + ], +) +async def test_migration_ti_cc_to_znp( + old_type: str, new_type: str, hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test zigpy-cc to zigpy-znp config migration.""" + config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} + config_entry.version = 2 + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.zha.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 2 + assert config_entry.data[CONF_RADIO_TYPE] == new_type diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 737604482d8..7d45960d576 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -222,10 +222,11 @@ async def test_fan( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -624,10 +625,11 @@ async def test_fan_ikea( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode( hass, entity_id, preset_mode="invalid does not exist" ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA @@ -813,8 +815,9 @@ async def test_fan_kof( # set invalid preset_mode from HA cluster.write_attributes.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(cluster.write_attributes.mock_calls) == 0 # test adding new fan to the network and HA diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 2a0a241c864..4f520920704 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -4,22 +4,21 @@ from unittest.mock import MagicMock, patch import pytest from zigpy.application import ControllerApplication -import zigpy.exceptions import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting -from homeassistant.components.zha.core.const import RadioType -from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .common import async_find_group_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import MockConfigEntry + IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" @@ -224,101 +223,6 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", - MagicMock(), -) -@patch( - "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", - MagicMock(), -) -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -@pytest.mark.parametrize( - "startup_effect", - [ - [asyncio.TimeoutError(), FileNotFoundError(), None], - [asyncio.TimeoutError(), None], - [None], - ], -) -async def test_gateway_initialize_success( - startup_effect: list[Exception | None], - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA initializing the gateway successfully.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = startup_effect - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == len(startup_effect) - device_light_1.async_cleanup_handles() - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - asyncio.TimeoutError(), - RuntimeError(), - FileNotFoundError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(FileNotFoundError): - await zha_gateway.async_initialize() - - assert zigpy_app_controller.startup.call_count == 3 - - -@patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) -async def test_gateway_initialize_failure_transient( - hass: HomeAssistant, - device_light_1: ZHADevice, - coordinator: ZHADevice, - zigpy_app_controller: ControllerApplication, -) -> None: - """Test ZHA failing to initialize the gateway but with a transient error.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None - - zigpy_app_controller.startup.side_effect = [ - RuntimeError(), - zigpy.exceptions.TransientConnectionError(), - ] - zigpy_app_controller.startup.reset_mock() - - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ), pytest.raises(ConfigEntryNotReady): - await zha_gateway.async_initialize() - - # Initialization immediately stops and is retried after TransientConnectionError - assert zigpy_app_controller.startup.call_count == 2 - - @patch( "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", MagicMock(), @@ -340,22 +244,25 @@ async def test_gateway_initialize_bellows_thread( thread_state: bool, config_override: dict, hass: HomeAssistant, - coordinator: ZHADevice, zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) - await zha_gateway.async_initialize() + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() - RadioType.ezsp.controller.new.mock_calls[-1].kwargs["config"][ - "use_thread" - ] is thread_state + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + + await zha_gateway.shutdown() @pytest.mark.parametrize( @@ -373,15 +280,14 @@ async def test_gateway_force_multi_pan_channel( config_override: dict, expected_channel: int | None, hass: HomeAssistant, - coordinator, + config_entry: MockConfigEntry, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" - zha_gateway = get_zha_gateway(hass) - assert zha_gateway is not None + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) - zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) - zha_gateway.config_entry.data["device"]["path"] = device_path - zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) _, config = zha_gateway.get_application_controller_data() assert config["network"]["channel"] == expected_channel diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index ad6ab4e351e..c2e9469c239 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,5 +1,6 @@ """Tests for ZHA integration init.""" import asyncio +import typing from unittest.mock import AsyncMock, Mock, patch import pytest @@ -9,6 +10,7 @@ from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, + CONF_FLOW_CONTROL, CONF_RADIO_TYPE, CONF_USB_PATH, DOMAIN, @@ -61,9 +63,8 @@ async def test_migration_from_v1_no_baudrate( assert config_entry_v1.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] assert CONF_USB_PATH not in config_entry_v1.data - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -80,7 +81,7 @@ async def test_migration_from_v1_with_baudrate( assert CONF_USB_PATH not in config_entry_v1.data assert CONF_BAUDRATE in config_entry_v1.data[CONF_DEVICE] assert config_entry_v1.data[CONF_DEVICE][CONF_BAUDRATE] == 115200 - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -95,8 +96,7 @@ async def test_migration_from_v1_wrong_baudrate( assert CONF_DEVICE in config_entry_v1.data assert config_entry_v1.data[CONF_DEVICE][CONF_DEVICE_PATH] == DATA_PORT_PATH assert CONF_USB_PATH not in config_entry_v1.data - assert CONF_BAUDRATE not in config_entry_v1.data[CONF_DEVICE] - assert config_entry_v1.version == 3 + assert config_entry_v1.version == 4 @pytest.mark.skipif( @@ -149,23 +149,74 @@ async def test_setup_with_v3_cleaning_uri( mock_zigpy_connect: ControllerApplication, ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" - config_entry_v3 = MockConfigEntry( + config_entry_v4 = MockConfigEntry( domain=DOMAIN, data={ CONF_RADIO_TYPE: DATA_RADIO_TYPE, - CONF_DEVICE: {CONF_DEVICE_PATH: path, CONF_BAUDRATE: 115200}, + CONF_DEVICE: { + CONF_DEVICE_PATH: path, + CONF_BAUDRATE: 115200, + CONF_FLOW_CONTROL: None, + }, }, - version=3, + version=4, ) - config_entry_v3.add_to_hass(hass) + config_entry_v4.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry_v3.entry_id) + await hass.config_entries.async_setup(config_entry_v4.entry_id) await hass.async_block_till_done() - await hass.config_entries.async_unload(config_entry_v3.entry_id) + await hass.config_entries.async_unload(config_entry_v4.entry_id) - assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE - assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path - assert config_entry_v3.version == 3 + assert config_entry_v4.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert config_entry_v4.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path + assert config_entry_v4.version == 4 + + +@pytest.mark.parametrize( + ( + "radio_type", + "old_baudrate", + "old_flow_control", + "new_baudrate", + "new_flow_control", + ), + [ + ("znp", None, None, 115200, None), + ("znp", None, "software", 115200, "software"), + ("znp", 57600, "software", 57600, "software"), + ("deconz", None, None, 38400, None), + ("deconz", 115200, None, 115200, None), + ], +) +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_migration_baudrate_and_flow_control( + radio_type: str, + old_baudrate: int, + old_flow_control: typing.Literal["hardware", "software", None], + new_baudrate: int, + new_flow_control: typing.Literal["hardware", "software", None], + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test baudrate and flow control migration.""" + config_entry.data = { + **config_entry.data, + CONF_RADIO_TYPE: radio_type, + CONF_DEVICE: { + CONF_BAUDRATE: old_baudrate, + CONF_FLOW_CONTROL: old_flow_control, + CONF_DEVICE_PATH: "/dev/null", + }, + } + config_entry.version = 3 + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version > 3 + assert config_entry.data[CONF_DEVICE][CONF_BAUDRATE] == new_baudrate + assert config_entry.data[CONF_DEVICE][CONF_FLOW_CONTROL] == new_flow_control @patch( diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 9c79578843c..d168e2e57b1 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -95,6 +95,7 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) @pytest.mark.parametrize( ("detected_hardware", "expected_learn_more_url"), [ @@ -188,6 +189,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( assert issue is None +@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1) async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -312,6 +314,8 @@ async def test_inconsistent_settings_keep_new( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, @@ -388,6 +392,8 @@ async def test_inconsistent_settings_restore_old( data = await resp.json() assert data["type"] == "create_entry" + await hass.config_entries.async_unload(config_entry.entry_id) + assert ( issue_registry.async_get_issue( domain=DOMAIN, diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index d914c88c0c2..44006ea6ca1 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -62,7 +62,7 @@ from .conftest import ( ) from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS -from tests.common import MockUser +from tests.common import MockConfigEntry, MockUser IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" @@ -295,10 +295,12 @@ async def test_get_zha_config_with_alarm( async def test_update_zha_config( - zha_client, app_controller: ControllerApplication + hass: HomeAssistant, + config_entry: MockConfigEntry, + zha_client, + app_controller: ControllerApplication, ) -> None: """Test updating ZHA custom configuration.""" - configuration: dict = deepcopy(CONFIG_WITH_ALARM_OPTIONS) configuration["data"]["zha_options"]["default_light_transition"] = 10 @@ -312,10 +314,12 @@ async def test_update_zha_config( msg = await zha_client.receive_json() assert msg["success"] - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + msg = await zha_client.receive_json() + configuration = msg["result"] + assert configuration == configuration + + await hass.config_entries.async_unload(config_entry.entry_id) async def test_device_not_found(zha_client) -> None: diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index c193cd509f3..65ef55c4711 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -2178,6 +2178,16 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_instantaneous_demand", + }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 92141eec3ff..c26a5366d37 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -536,13 +536,14 @@ async def test_inovelli_lzw36( assert args["value"] == 1 client.async_send_command.reset_mock() - with pytest.raises(NotValidPresetModeError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( "fan", "turn_on", {"entity_id": entity_id, "preset_mode": "wheeze"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" assert len(client.async_send_command.call_args_list) == 0 @@ -675,13 +676,14 @@ async def test_thermostat_fan( client.async_send_command.reset_mock() # Test setting unknown preset mode - with pytest.raises(ValueError): + with pytest.raises(NotValidPresetModeError) as exc: await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "Turbo"}, blocking=True, ) + assert exc.value.translation_key == "not_valid_preset_mode" client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index bf015a70676..75a7397cc4e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1650,6 +1650,7 @@ async def test_factory_reset_node( hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration ) -> None: """Test when a node is removed because it was reset.""" + dev_reg = dr.async_get(hass) # One config entry scenario remove_event = Event( type="node removed", @@ -1670,15 +1671,25 @@ async def test_factory_reset_node( assert notifications[msg_id]["message"].startswith("`Multisensor 6`") assert "with the home ID" not in notifications[msg_id]["message"] async_dismiss(hass, msg_id) + await hass.async_block_till_done() + assert not dev_reg.async_get_device(identifiers={dev_id}) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) new_entry.add_to_hass(hass) # Re-add the node then remove it again - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) # Test case where config entry title and home ID don't match @@ -1686,16 +1697,24 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert ( - "network `Mock Title`, with the home ID `3245146787`." + "network `Mock Title`, with the home ID `3245146787`" in notifications[msg_id]["message"] ) async_dismiss(hass, msg_id) # Test case where config entry title and home ID do match hass.config_entries.async_update_entry(integration, title="3245146787") - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) notifications = async_get_persistent_notifications(hass) diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 25553489b4e..26b9459cfc2 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -272,7 +272,7 @@ async def test_zwave_js_value_updated( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) @@ -834,7 +834,7 @@ async def test_zwave_js_event( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) diff --git a/tests/conftest.py b/tests/conftest.py index 4050c1cdb6a..777b2073847 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password from homeassistant.components.device_tracker.legacy import Device -from homeassistant.components.network.models import Adapter, IPv4ConfiguredAddress from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, @@ -383,7 +382,7 @@ def reset_hass_threading_local_object() -> Generator[None, None, None]: ha._hass.__dict__.clear() -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def bcrypt_cost() -> Generator[None, None, None]: """Run with reduced rounds during tests, to speed up uses.""" import bcrypt @@ -1096,21 +1095,18 @@ async def mqtt_mock_entry( yield _setup_mqtt_entry -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None, None, None]: """Mock network.""" - mock_adapter = Adapter( - name="eth0", - index=0, - enabled=True, - auto=True, - default=True, - ipv4=[IPv4ConfiguredAddress(address="10.10.10.10", network_prefix=24)], - ipv6=[], - ) with patch( - "homeassistant.components.network.network.async_load_adapters", - return_value=[mock_adapter], + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], ): yield @@ -1544,7 +1540,7 @@ async def mock_enable_bluetooth( await hass.async_block_till_done() -@pytest.fixture +@pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" with patch( @@ -1574,14 +1570,14 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # Late imports to avoid loading bleak unless we need it # pylint: disable-next=import-outside-toplevel - from homeassistant.components.bluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index 9c3d1eb190b..49db89f45ba 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -56,3 +56,8 @@ custom_validator_bad_1: # This always raises ValueError custom_validator_bad_2: + +# Invalid domains +"iot_domain ": +"": +5: diff --git a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml index 5744e3005fa..8e1c75c3511 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -8,3 +8,6 @@ custom_validator_ok_1: !include integrations/custom_validator_ok_1.yaml custom_validator_ok_2: !include integrations/custom_validator_ok_2.yaml custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml custom_validator_bad_2: !include integrations/custom_validator_bad_2.yaml +"iot_domain ": !include integrations/iot_domain .yaml +"": !include integrations/.yaml +5: !include integrations/5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index b8116b5988e..25d734b126a 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -68,3 +68,10 @@ homeassistant: pack_custom_validator_bad_2: # This always raises ValueError custom_validator_bad_2: + # Invalid domains + pack_iot_domain_space: + "iot_domain ": + pack_empty: + "": + pack_5: + 5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml new file mode 100644 index 00000000000..70bf80a6b64 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml @@ -0,0 +1 @@ +5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml new file mode 100644 index 00000000000..510d4682445 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml @@ -0,0 +1 @@ +"": diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml new file mode 100644 index 00000000000..49b5720a536 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml @@ -0,0 +1 @@ +"iot_domain ": diff --git a/tests/helpers/test_aiohttp_compat.py b/tests/helpers/test_aiohttp_compat.py deleted file mode 100644 index 749984dbc2e..00000000000 --- a/tests/helpers/test_aiohttp_compat.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Test the aiohttp compatibility shim.""" - -import asyncio -from contextlib import suppress - -from aiohttp import client, web, web_protocol, web_server -import pytest - -from homeassistant.helpers.aiohttp_compat import CancelOnDisconnectRequestHandler - - -@pytest.mark.allow_hosts(["127.0.0.1"]) -async def test_handler_cancellation(socket_enabled, unused_tcp_port_factory) -> None: - """Test that handler cancels the request on disconnect. - - From aiohttp tests/test_web_server.py - """ - assert web_protocol.RequestHandler is CancelOnDisconnectRequestHandler - assert web_server.RequestHandler is CancelOnDisconnectRequestHandler - - event = asyncio.Event() - port = unused_tcp_port_factory() - - async def on_request(_: web.Request) -> web.Response: - nonlocal event - try: - await asyncio.sleep(10) - except asyncio.CancelledError: - event.set() - raise - else: - raise web.HTTPInternalServerError() - - app = web.Application() - app.router.add_route("GET", "/", on_request) - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, host="127.0.0.1", port=port) - - await site.start() - - try: - async with client.ClientSession( - timeout=client.ClientTimeout(total=0.1) - ) as sess: - with pytest.raises(asyncio.TimeoutError): - await sess.get(f"http://127.0.0.1:{port}/") - - with suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout=1) - assert event.is_set(), "Request handler hasn't been cancelled" - finally: - await asyncio.gather(runner.shutdown(), site.stop()) diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index b65f09aeaf9..de57fa0a8f3 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -227,7 +227,12 @@ async def test_platform_not_found(hass: HomeAssistant) -> None: assert res["light"] == [] warning = CheckConfigError( - "Platform error light.beer - Integration 'beer' not found.", None, None + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), + None, + None, ) _assert_warnings_errors(res, [warning], []) @@ -361,7 +366,7 @@ async def test_platform_import_error(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} warning = CheckConfigError( - "Platform error light.demo - blablabla", + "Platform error 'light' from integration 'demo' - blablabla", None, None, ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 3b8217028cc..bcb6f4fa971 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1137,7 +1138,7 @@ async def test_state_for(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -1163,7 +1164,7 @@ async def test_state_for_template(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -2235,7 +2236,7 @@ async def test_if_action_before_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2247,7 +2248,7 @@ async def test_if_action_before_sunrise_no_offset( # now = sunrise -> 'before sunrise' true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2259,7 +2260,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight -> 'before sunrise' true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2271,7 +2272,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2306,7 +2307,7 @@ async def test_if_action_after_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2318,7 +2319,7 @@ async def test_if_action_after_sunrise_no_offset( # now = sunrise + 1s -> 'after sunrise' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2330,7 +2331,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2342,7 +2343,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2381,7 +2382,7 @@ async def test_if_action_before_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2393,7 +2394,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunrise + 1h -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2405,7 +2406,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2417,7 +2418,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2429,7 +2430,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2441,7 +2442,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2453,7 +2454,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2465,7 +2466,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2504,7 +2505,7 @@ async def test_if_action_before_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = local midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2516,7 +2517,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2528,7 +2529,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1h -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2540,7 +2541,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2552,7 +2553,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight - 1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2564,7 +2565,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2576,7 +2577,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2588,7 +2589,7 @@ async def test_if_action_before_sunset_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2627,7 +2628,7 @@ async def test_if_action_after_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2639,7 +2640,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunrise + 1h -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2651,7 +2652,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2663,7 +2664,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2675,7 +2676,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2687,7 +2688,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon - 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2699,7 +2700,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2711,7 +2712,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset + 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2723,7 +2724,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2735,7 +2736,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2774,7 +2775,7 @@ async def test_if_action_after_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2786,7 +2787,7 @@ async def test_if_action_after_sunset_with_offset( # now = sunset + 1h -> 'after sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2798,7 +2799,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight-1s -> 'after sunset' with offset +1h true now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2810,7 +2811,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2849,7 +2850,7 @@ async def test_if_action_after_and_before_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2865,7 +2866,7 @@ async def test_if_action_after_and_before_during( # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2877,7 +2878,7 @@ async def test_if_action_after_and_before_during( # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2893,7 +2894,7 @@ async def test_if_action_after_and_before_during( # now = sunset - 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2909,7 +2910,7 @@ async def test_if_action_after_and_before_during( # now = 9AM local -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2952,7 +2953,7 @@ async def test_if_action_before_or_after_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2968,7 +2969,7 @@ async def test_if_action_before_or_after_during( # now = sunset + 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2984,7 +2985,7 @@ async def test_if_action_before_or_after_during( # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3000,7 +3001,7 @@ async def test_if_action_before_or_after_during( # now = sunset - 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3016,7 +3017,7 @@ async def test_if_action_before_or_after_during( # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -3032,7 +3033,7 @@ async def test_if_action_before_or_after_during( # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -3077,7 +3078,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3089,7 +3090,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'before sunrise' true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3101,7 +3102,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3113,7 +3114,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3154,7 +3155,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise -> 'after sunrise' true now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3166,7 +3167,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'after sunrise' not true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3178,7 +3179,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3190,7 +3191,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3231,7 +3232,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset + 1s -> 'before sunset' not true now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3243,7 +3244,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = sunset - 1h-> 'before sunset' true now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3255,7 +3256,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3267,7 +3268,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3308,7 +3309,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset -> 'after sunset' true now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3320,7 +3321,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = sunset - 1s -> 'after sunset' not true now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3332,7 +3333,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight -> 'after sunset' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3344,7 +3345,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunset' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index a9ddd89a0b3..f997e3a6c10 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -539,6 +539,13 @@ def test_string(hass: HomeAssistant) -> None: for value in (True, 1, "hello"): schema(value) + # Test subclasses of str are returned + class MyString(str): + pass + + my_string = MyString("hello") + assert schema(my_string) is my_string + # Test template support for text, native in ( ("[1, 2]", [1, 2]), @@ -832,6 +839,7 @@ def test_selector_in_serializer() -> None: "selector": { "text": { "multiline": False, + "multiple": False, } } } @@ -1623,3 +1631,19 @@ def test_platform_only_schema( cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + +def test_domain() -> None: + """Test domain.""" + with pytest.raises(vol.Invalid): + cv.domain_key(5) + with pytest.raises(vol.Invalid): + cv.domain_key("") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + assert cv.domain_key("hue") == "hue" + assert cv.domain_key("hue1") == "hue1" + assert cv.domain_key("hue 1") == "hue" + assert cv.domain_key("hue 1") == "hue" diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 1216bd6e293..46716263d5b 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -119,32 +119,52 @@ def test_deprecated_class(mock_get_logger) -> None: assert len(mock_logger.warning.mock_calls) == 1 -def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) +def test_deprecated_function( + caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is not known. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass mock_deprecated_function() assert ( - "mock_deprecated_function is a deprecated function. Use new_function instead" - in caplog.text - ) + f"mock_deprecated_function is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_built_in_integration( caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is built-in. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -170,14 +190,24 @@ def test_deprecated_function_called_from_built_in_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. @@ -186,7 +216,7 @@ def test_deprecated_function_called_from_custom_integration( mock_integration(hass, MockModule("hue"), built_in=False) - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -212,7 +242,8 @@ def test_deprecated_function_called_from_custom_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead, please report it to the author of the 'hue' custom " - "integration" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead, please report it to the author of the " + "'hue' custom integration" + ) in caplog.text diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 40e25633992..60d0774b549 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -215,7 +215,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # Should not trigger attempt 2 async_fire_time_changed(hass, utcnow + timedelta(seconds=29)) @@ -226,7 +226,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=30)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 2 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # This should not trigger attempt 3 async_fire_time_changed(hass, utcnow + timedelta(seconds=59)) @@ -237,7 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 3 - assert "test_domain.mod1" in hass.config.components + assert "mod1.test_domain" in hass.config.components async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: @@ -317,7 +317,7 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert "test_component" in hass.config.components assert "test_component2" in hass.config.components - assert "test_domain.test_component" in hass.config.components + assert "test_component.test_domain" in hass.config.components async def test_setup_entry(hass: HomeAssistant) -> None: @@ -680,7 +680,7 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components with patch.object( component._platforms[DOMAIN], "async_shutdown" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 721114c1a7b..dfaec4577aa 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -268,7 +268,7 @@ async def test_platform_error_slow_setup( await component.async_setup({DOMAIN: {"platform": "test_platform"}}) await hass.async_block_till_done() assert len(called) == 1 - assert "test_domain.test_platform" not in hass.config.components + assert "test_platform.test_domain" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text # Cleanup lingering (setup_platform) task after test is done @@ -833,7 +833,7 @@ async def test_setup_entry( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 1 assert len(entity_registry.entities) == 1 @@ -856,7 +856,7 @@ async def test_setup_entry_platform_not_ready( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 assert "Platform test not ready yet" in caplog.text @@ -877,7 +877,7 @@ async def test_setup_entry_platform_not_ready_with_message( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -904,7 +904,7 @@ async def test_setup_entry_platform_not_ready_from_exception( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -1669,7 +1669,7 @@ async def test_setup_entry_with_entities_that_block_forever( ): assert await platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 assert len(entity_registry.entities) == 1 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 00ad580693e..245354a09a0 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -923,7 +923,9 @@ async def test_track_template_error_can_recover( async def test_track_template_time_change( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test tracking template with time change.""" template_error = Template("{{ utcnow().minute % 2 == 0 }}", hass) @@ -935,17 +937,15 @@ async def test_track_template_time_change( start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_template(hass, template_error, error_callback) - await hass.async_block_till_done() - assert not calls + freezer.move_to(time_that_will_not_match_right_away) + unsub = async_track_template(hass, template_error, error_callback) + await hass.async_block_till_done() + assert not calls first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0] == (None, None, None) @@ -3312,84 +3312,89 @@ async def test_track_template_with_time_default(hass: HomeAssistant) -> None: info.async_remove() -async def test_track_template_with_time_that_leaves_scope(hass: HomeAssistant) -> None: +async def test_track_template_with_time_that_leaves_scope( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test tracking template with time.""" now = dt_util.utcnow() test_time = datetime(now.year + 1, 5, 24, 11, 59, 1, 500000, tzinfo=dt_util.UTC) + freezer.move_to(test_time) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - hass.states.async_set("binary_sensor.washing_machine", "on") - specific_runs = [] - template_complex = Template( - """ - {% if states.binary_sensor.washing_machine.state == "on" %} - {{ now() }} - {% else %} - {{ states.binary_sensor.washing_machine.last_updated }} - {% endif %} - """, - hass, - ) + hass.states.async_set("binary_sensor.washing_machine", "on") + specific_runs = [] + template_complex = Template( + """ + {% if states.binary_sensor.washing_machine.state == "on" %} + {{ now() }} + {% else %} + {{ states.binary_sensor.washing_machine.last_updated }} + {% endif %} + """, + hass, + ) - def specific_run_callback( - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - specific_runs.append(updates.pop().result) + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + specific_runs.append(updates.pop().result) - info = async_track_template_result( - hass, [TrackTemplate(template_complex, None)], specific_run_callback - ) - await hass.async_block_till_done() + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - hass.states.async_set("binary_sensor.washing_machine", "off") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "off") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": False, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": False, + } - hass.states.async_set("binary_sensor.washing_machine", "on") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "on") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - # Verify we do not update before the minute rolls over - callback_count_before_time_change = len(specific_runs) - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + # Verify we do not update before the minute rolls over + callback_count_before_time_change = len(specific_runs) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - async_fire_time_changed(hass, test_time + timedelta(seconds=58)) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + new_time = test_time + timedelta(seconds=58) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - # Verify we do update on the next change of minute - async_fire_time_changed(hass, test_time + timedelta(seconds=59)) - - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + 1 + # Verify we do update on the next change of minute + new_time = test_time + timedelta(seconds=59) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + 1 info.async_remove() async def test_async_track_template_result_multiple_templates_mixing_listeners( - hass: HomeAssistant, + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test tracking multiple templates with mixing listener types.""" @@ -3410,18 +3415,16 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - info = async_track_template_result( - hass, - [ - TrackTemplate(template_1, None), - TrackTemplate(template_2, None), - ], - refresh_listener, - ) + info = async_track_template_result( + hass, + [ + TrackTemplate(template_1, None), + TrackTemplate(template_2, None), + ], + refresh_listener, + ) assert info.listeners == { "all": False, @@ -3450,9 +3453,9 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( refresh_runs = [] next_time = time_that_will_not_match_right_away + timedelta(hours=25) - with patch("homeassistant.util.dt.utcnow", return_value=next_time): - async_fire_time_changed(hass, next_time) - await hass.async_block_till_done() + freezer.move_to(next_time) + async_fire_time_changed(hass, next_time) + await hass.async_block_till_done() assert refresh_runs == [ [ @@ -3787,7 +3790,10 @@ async def test_track_sunset(hass: HomeAssistant) -> None: assert len(offset_runs) == 1 -async def test_async_track_time_change(hass: HomeAssistant) -> None: +async def test_async_track_time_change( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test tracking time change.""" none_runs = [] wildcard_runs = [] @@ -3798,21 +3804,19 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) - unsub_utc = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] - ) - unsub_wildcard = async_track_time_change( - hass, - callback(lambda x: wildcard_runs.append(x)), - second="*", - minute="*", - hour="*", - ) + unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) + unsub_utc = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] + ) + unsub_wildcard = async_track_time_change( + hass, + callback(lambda x: wildcard_runs.append(x)), + second="*", + minute="*", + hour="*", + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3851,7 +3855,10 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: assert len(none_runs) == 3 -async def test_periodic_task_minute(hass: HomeAssistant) -> None: +async def test_periodic_task_minute( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per minute.""" specific_runs = [] @@ -3860,13 +3867,11 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 - ) + unsub = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3895,7 +3900,10 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 -async def test_periodic_task_hour(hass: HomeAssistant) -> None: +async def test_periodic_task_hour( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per hour.""" specific_runs = [] @@ -3904,17 +3912,15 @@ async def test_periodic_task_hour(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3973,71 +3979,77 @@ async def test_periodic_task_wrong_input(hass: HomeAssistant) -> None: assert len(specific_runs) == 0 -async def test_periodic_task_clock_rollback(hass: HomeAssistant) -> None: +async def test_periodic_task_clock_rollback( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic tasks with the time rolling backwards.""" specific_runs = [] now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) - - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) + + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 -async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: +async def test_periodic_task_duplicate_time( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] @@ -4046,17 +4058,15 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index c567c6bc7bc..39b387000ca 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -2,10 +2,12 @@ from collections import OrderedDict +import pytest + from homeassistant import helpers -def test_extract_domain_configs() -> None: +def test_extract_domain_configs(caplog: pytest.LogCaptureFixture) -> None: """Test the extraction of domain configuration.""" config = { "zone": None, @@ -19,8 +21,13 @@ def test_extract_domain_configs() -> None: helpers.extract_domain_configs(config, "zone") ) + assert ( + "helpers.extract_domain_configs is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.extract_domain_configs instead" in caplog.text + ) -def test_config_per_platform() -> None: + +def test_config_per_platform(caplog: pytest.LogCaptureFixture) -> None: """Test config per platform method.""" config = OrderedDict( [ @@ -36,3 +43,8 @@ def test_config_per_platform() -> None: (None, 1), ("hello 2", config["zone Hallo"][1]), ] == list(helpers.config_per_platform(config, "zone")) + + assert ( + "helpers.config_per_platform is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.config_per_platform instead" in caplog.text + ) diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 586dbc19eb8..4425ce00ce1 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -53,7 +53,7 @@ async def test_reload_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) @@ -93,7 +93,7 @@ async def test_setup_reload_service(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -134,7 +134,7 @@ async def test_setup_reload_service_when_async_process_component_config_fails( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -186,7 +186,7 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index f01718d6af6..d69996e5d29 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -508,7 +508,7 @@ async def test_restore_entity_end_to_end( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 7e655a69c0a..c2bad6287ab 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,6 +9,7 @@ from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1346,13 +1347,13 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running match_time = start_time.replace(hour=12) - with patch("homeassistant.util.dt.utcnow", return_value=match_time): + with freeze_time(match_time): async_fire_time_changed(hass, match_time) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() @@ -1378,15 +1379,13 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running second_non_matching_time = start_time.replace(hour=4) - with patch( - "homeassistant.util.dt.utcnow", return_value=second_non_matching_time - ): + with freeze_time(second_non_matching_time): async_fire_time_changed(hass, second_non_matching_time) async with asyncio.timeout(0.1): diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 1e449fd103a..c4ad244620b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -602,6 +602,11 @@ def test_object_selector_schema(schema, valid_selections, invalid_selections) -> ({"multiline": True}, (), ()), ({"multiline": False, "type": "email"}, (), ()), ({"prefix": "before", "suffix": "after"}, (), ()), + ( + {"multiple": True}, + (["abc123", "def456"],), + ("abc123", None, ["abc123", None]), + ), ), ) def test_text_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -907,6 +912,16 @@ def test_rgb_color_selector_schema( (100, 200), (99, 201), ), + ( + {"unit": "mired", "min": 100, "max": 200}, + (100, 200), + (99, 201), + ), + ( + {"unit": "kelvin", "min": 1000, "max": 2000}, + (1000, 2000), + (999, 2001), + ), ), ) def test_color_tempselector_schema( diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index e030958ab82..b6dc1616a48 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -1,8 +1,8 @@ """The tests for the Sun helpers.""" from datetime import datetime, timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET @@ -77,7 +77,7 @@ def test_next_events(hass: HomeAssistant) -> None: break mod += 1 - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert next_dawn == sun.get_astral_event_next(hass, "dawn") assert next_dusk == sun.get_astral_event_next(hass, "dusk") assert next_midnight == sun.get_astral_event_next(hass, "midnight") @@ -132,7 +132,7 @@ def test_date_events_default_date(hass: HomeAssistant) -> None: sunrise = astral.sun.sunrise(location.observer, date=utc_today) sunset = astral.sun.sunset(location.observer, date=utc_today) - with patch("homeassistant.util.dt.now", return_value=utc_now): + with freeze_time(utc_now): assert dawn == sun.get_astral_event_date(hass, "dawn", utc_today) assert dusk == sun.get_astral_event_date(hass, "dusk", utc_today) assert midnight == sun.get_astral_event_date(hass, "midnight", utc_today) @@ -171,11 +171,11 @@ def test_date_events_accepts_datetime(hass: HomeAssistant) -> None: def test_is_up(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert not sun.is_up(hass) utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert sun.is_up(hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 79358ec588d..58d52dfc395 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1233,6 +1233,22 @@ def test_to_json(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ {'Foo': now()} | to_json }}", hass).async_render() + # Test special case where substring class cannot be rendered + # See: https://github.com/ijl/orjson/issues/445 + class MyStr(str): + pass + + expected_result = '{"mykey1":11.0,"mykey2":"myvalue2","mykey3":["opt3b","opt3a"]}' + test_dict = { + MyStr("mykey2"): "myvalue2", + MyStr("mykey1"): 11.0, + MyStr("mykey3"): ["opt3b", "opt3a"], + } + actual_result = template.Template( + "{{ test_dict | to_json(sort_keys=True) }}", hass + ).async_render(parse_result=False, variables={"test_dict": test_dict}) + assert actual_result == expected_result + def test_to_json_ensure_ascii(hass: HomeAssistant) -> None: """Test the object to JSON string filter.""" @@ -1853,7 +1869,7 @@ def test_has_value(hass: HomeAssistant) -> None: def test_now(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): info = template.Template("{{ now().isoformat() }}", hass).async_render_to_info() assert now.isoformat() == info.result() @@ -1867,7 +1883,7 @@ def test_now(mock_is_safe, hass: HomeAssistant) -> None: def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + with freeze_time(utcnow): info = template.Template( "{{ utcnow().isoformat() }}", hass ).async_render_to_info() @@ -1954,7 +1970,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( relative_time_template, hass, @@ -2026,7 +2042,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( "{{timedelta(seconds=120)}}", hass, diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 6f5b4253218..62152299932 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -56,14 +56,14 @@ async def test_component_translation_path( ) assert path.normpath( - translation.component_translation_path("switch.test", "en", int_test) + translation.component_translation_path("test.switch", "en", int_test) ) == path.normpath( hass.config.path("custom_components", "test", "translations", "switch.en.json") ) assert path.normpath( translation.component_translation_path( - "switch.test_embedded", "en", int_test_embedded + "test_embedded.switch", "en", int_test_embedded ) ) == path.normpath( hass.config.path( @@ -255,7 +255,7 @@ async def test_translation_merging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we merge translations of two integrations.""" - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") hass.config.components.add("sensor") orig_load_translations = translation.load_translations_files @@ -263,7 +263,7 @@ async def test_translation_merging( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -276,13 +276,13 @@ async def test_translation_merging( assert "component.sensor.state.moon__phase.first_quarter" in translations - hass.config.components.add("sensor.season") + hass.config.components.add("season.sensor") # Patch in some bad translation data def mock_load_bad_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.season"] = {"state": "bad data"} + result["season.sensor"] = {"state": "bad data"} return result with patch( @@ -308,7 +308,7 @@ async def test_translation_merging_loaded_apart( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -323,7 +323,7 @@ async def test_translation_merging_loaded_apart( assert "component.sensor.state.moon__phase.first_quarter" not in translations - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") with patch( "homeassistant.helpers.translation.load_translations_files", diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 06dff1e0869..425ad561f50 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -78,7 +78,10 @@ def test_config_platform_valid( ( BASE_CONFIG + "light:\n platform: beer", {"homeassistant", "light"}, - "Platform error light.beer - Integration 'beer' not found.", + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 7438bda5cde..76d3f0c4666 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,24 +1,36 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 62", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -57,24 +69,36 @@ # --- # name: test_component_config_validation_error[basic_include] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 11", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 12", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -119,18 +143,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -143,42 +167,54 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 ''', }), ]) # --- # name: test_component_config_validation_error[packages] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at configuration.yaml, line 72 failed: Invalid domain 'iot_domain '", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at configuration.yaml, line 74 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at configuration.yaml, line 76 failed: Invalid domain '5'", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -217,6 +253,18 @@ # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at integrations/pack_5.yaml, line 1 failed: Invalid domain '5'", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at integrations/pack_empty.yaml, line 1 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at integrations/pack_iot_domain_space.yaml, line 1 failed: Invalid domain 'iot_domain '", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided", @@ -255,31 +303,34 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), ]) # --- # name: test_component_config_validation_error_with_docs[basic] list([ + "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + "Invalid domain '' at configuration.yaml, line 62", + "Invalid domain '5' at configuration.yaml, line 1", "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 ''', "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index b98d3d0311f..42d679d7ce6 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -40,7 +40,7 @@ async def apply_stop_hass(stop_hass: None) -> None: """Make sure all hass are stopped.""" -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with patch( diff --git a/tests/test_config.py b/tests/test_config.py index de5e7e0581d..8ec509cd895 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2043,7 +2043,7 @@ async def test_component_config_validation_error( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2088,7 +2088,7 @@ async def test_component_config_validation_error_with_docs( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2207,3 +2207,36 @@ async def test_yaml_error( if record.levelno == logging.ERROR ] assert error_records == snapshot + + +def test_extract_domain_configs() -> None: + """Test the extraction of domain configuration.""" + config = { + "zone": None, + "zoner": None, + "zone ": None, + "zone Hallo": None, + "zone 100": None, + } + + assert {"zone", "zone Hallo", "zone 100"} == set( + config_util.extract_domain_configs(config, "zone") + ) + + +def test_config_per_platform() -> None: + """Test config per platform method.""" + config = OrderedDict( + [ + ("zone", {"platform": "hello"}), + ("zoner", None), + ("zone Hallo", [1, {"platform": "hello 2"}]), + ("zone 100", None), + ] + ) + + assert [ + ("hello", config["zone"]), + (None, 1), + ("hello 2", config["zone Hallo"][1]), + ] == list(config_util.config_per_platform(config, "zone")) diff --git a/tests/test_core.py b/tests/test_core.py index 43291c032d7..ce1767f2755 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,6 +14,7 @@ import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +from freezegun import freeze_time import pytest from pytest_unordered import unordered import voluptuous as vol @@ -36,6 +37,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import ( + CoreState, HassJob, HomeAssistant, ServiceCall, @@ -399,6 +401,32 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None: assert len(test_all) == 2 +async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: + """Simulate a shutdown, test timeouts at each step.""" + + with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): + await hass.async_stop() + + assert hass.state == CoreState.stopped + + +async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: + """Simulate a shutdown, test that a generic error at the final stage doesn't prevent it.""" + + task = asyncio.Future() + hass._tasks.add(task) + + def fail_the_task(_): + task.set_exception(Exception("test_exception")) + + with patch.object(task, "cancel", side_effect=fail_the_task) as patched_call: + await hass.async_stop() + assert patched_call.called + + assert "test_exception" in caplog.text + assert hass.state == ha.CoreState.stopped + + async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff with exit code checks.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) @@ -1102,7 +1130,7 @@ async def test_statemachine_last_changed_not_updated_on_same_state( future = dt_util.utcnow() + timedelta(hours=10) - with patch("homeassistant.util.dt.utcnow", return_value=future): + with freeze_time(future): hass.states.async_set("light.Bowl", "on", {"attr": "triggers_change"}) await hass.async_block_till_done() @@ -2566,3 +2594,30 @@ def test_hassjob_passing_job_type(): HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type == ha.HassJobType.Callback ) + + +async def test_shutdown_job(hass: HomeAssistant) -> None: + """Test async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + hass.async_add_shutdown_job(job) + await hass.async_stop() + assert evt.is_set() + + +async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: + """Test cancelling a job added to async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + cancel = hass.async_add_shutdown_job(job) + cancel() + await hass.async_stop() + assert not evt.is_set() diff --git a/tests/test_runner.py b/tests/test_runner.py index 3b06e3b64dc..14728321721 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import executor, thread # https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py -SUPERVISOR_HARD_TIMEOUT = 220 +SUPERVISOR_HARD_TIMEOUT = 240 TIMEOUT_SAFETY_MARGIN = 10 @@ -21,9 +21,10 @@ TIMEOUT_SAFETY_MARGIN = 10 async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: """Verify the cumulative shutdown timeout is at least 10s less than the supervisor.""" assert ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + TIMEOUT_SAFETY_MARGIN diff --git a/tests/test_setup.py b/tests/test_setup.py index 00bb3fa2a2d..14c56d39a5a 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -672,7 +672,7 @@ async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: hass.config.components.add("notbase.switch") hass.config.components.add("myintegration") hass.config.components.add("device_tracker") - hass.config.components.add("device_tracker.other") + hass.config.components.add("other.device_tracker") hass.config.components.add("myintegration.light") assert setup.async_get_loaded_integrations(hass) == { "other", @@ -729,9 +729,9 @@ async def test_async_start_setup(hass: HomeAssistant) -> None: async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times for platforms.""" - with setup.async_start_setup(hass, ["sensor.august"]): + with setup.async_start_setup(hass, ["august.sensor"]): assert isinstance( - hass.data[setup.DATA_SETUP_STARTED]["sensor.august"], datetime.datetime + hass.data[setup.DATA_SETUP_STARTED]["august.sensor"], datetime.datetime ) assert "august" not in hass.data[setup.DATA_SETUP_STARTED] diff --git a/tests/testing_config/custom_components/test/fan.py b/tests/testing_config/custom_components/test/fan.py new file mode 100644 index 00000000000..133f372f4fa --- /dev/null +++ b/tests/testing_config/custom_components/test/fan.py @@ -0,0 +1,64 @@ +"""Provide a mock fan platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from tests.common import MockEntity + +ENTITIES = {} + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + {} + if empty + else { + "support_preset_mode": MockFan( + name="Support fan with preset_mode support", + supported_features=FanEntityFeature.PRESET_MODE, + unique_id="unique_support_preset_mode", + preset_modes=["auto", "eco"], + ) + } + ) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities_callback: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +): + """Return mock entities.""" + async_add_entities_callback(list(ENTITIES.values())) + + +class MockFan(MockEntity, FanEntity): + """Mock Fan class.""" + + @property + def preset_mode(self) -> str | None: + """Return preset mode.""" + return self._handle("preset_mode") + + @property + def preset_modes(self) -> list[str] | None: + """Return preset mode.""" + return self._handle("preset_modes") + + @property + def supported_features(self): + """Return the class of this fan.""" + return self._handle("supported_features") + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + self._attr_preset_mode = preset_mode + await self.async_update_ha_state() diff --git a/tests/util/snapshots/test_color.ambr b/tests/util/snapshots/test_color.ambr new file mode 100644 index 00000000000..514502131fb --- /dev/null +++ b/tests/util/snapshots/test_color.ambr @@ -0,0 +1,519 @@ +# serializer version: 1 +# name: test_brightness_to_254_range + dict({ + 1: 0.996078431372549, + 2: 1.992156862745098, + 3: 2.988235294117647, + 4: 3.984313725490196, + 5: 4.980392156862745, + 6: 5.976470588235294, + 7: 6.972549019607843, + 8: 7.968627450980392, + 9: 8.964705882352941, + 10: 9.96078431372549, + 11: 10.95686274509804, + 12: 11.952941176470588, + 13: 12.949019607843137, + 14: 13.945098039215686, + 15: 14.941176470588236, + 16: 15.937254901960785, + 17: 16.933333333333334, + 18: 17.929411764705883, + 19: 18.92549019607843, + 20: 19.92156862745098, + 21: 20.91764705882353, + 22: 21.91372549019608, + 23: 22.909803921568628, + 24: 23.905882352941177, + 25: 24.901960784313726, + 26: 25.898039215686275, + 27: 26.894117647058824, + 28: 27.890196078431373, + 29: 28.886274509803922, + 30: 29.88235294117647, + 31: 30.87843137254902, + 32: 31.87450980392157, + 33: 32.870588235294115, + 34: 33.86666666666667, + 35: 34.86274509803921, + 36: 35.858823529411765, + 37: 36.85490196078431, + 38: 37.85098039215686, + 39: 38.84705882352941, + 40: 39.84313725490196, + 41: 40.83921568627451, + 42: 41.83529411764706, + 43: 42.831372549019605, + 44: 43.82745098039216, + 45: 44.8235294117647, + 46: 45.819607843137256, + 47: 46.8156862745098, + 48: 47.811764705882354, + 49: 48.8078431372549, + 50: 49.80392156862745, + 51: 50.8, + 52: 51.79607843137255, + 53: 52.792156862745095, + 54: 53.78823529411765, + 55: 54.78431372549019, + 56: 55.780392156862746, + 57: 56.77647058823529, + 58: 57.772549019607844, + 59: 58.76862745098039, + 60: 59.76470588235294, + 61: 60.76078431372549, + 62: 61.75686274509804, + 63: 62.752941176470586, + 64: 63.74901960784314, + 65: 64.74509803921569, + 66: 65.74117647058823, + 67: 66.73725490196078, + 68: 67.73333333333333, + 69: 68.72941176470589, + 70: 69.72549019607843, + 71: 70.72156862745098, + 72: 71.71764705882353, + 73: 72.71372549019608, + 74: 73.70980392156862, + 75: 74.70588235294117, + 76: 75.70196078431373, + 77: 76.69803921568628, + 78: 77.69411764705882, + 79: 78.69019607843137, + 80: 79.68627450980392, + 81: 80.68235294117648, + 82: 81.67843137254901, + 83: 82.67450980392157, + 84: 83.67058823529412, + 85: 84.66666666666667, + 86: 85.66274509803921, + 87: 86.65882352941176, + 88: 87.65490196078431, + 89: 88.65098039215687, + 90: 89.6470588235294, + 91: 90.64313725490196, + 92: 91.63921568627451, + 93: 92.63529411764706, + 94: 93.6313725490196, + 95: 94.62745098039215, + 96: 95.62352941176471, + 97: 96.61960784313726, + 98: 97.6156862745098, + 99: 98.61176470588235, + 100: 99.6078431372549, + 101: 100.60392156862746, + 102: 101.6, + 103: 102.59607843137255, + 104: 103.5921568627451, + 105: 104.58823529411765, + 106: 105.58431372549019, + 107: 106.58039215686274, + 108: 107.5764705882353, + 109: 108.57254901960785, + 110: 109.56862745098039, + 111: 110.56470588235294, + 112: 111.56078431372549, + 113: 112.55686274509804, + 114: 113.55294117647058, + 115: 114.54901960784314, + 116: 115.54509803921569, + 117: 116.54117647058824, + 118: 117.53725490196078, + 119: 118.53333333333333, + 120: 119.52941176470588, + 121: 120.52549019607844, + 122: 121.52156862745097, + 123: 122.51764705882353, + 124: 123.51372549019608, + 125: 124.50980392156863, + 126: 125.50588235294117, + 127: 126.50196078431372, + 128: 127.49803921568628, + 129: 128.49411764705883, + 130: 129.49019607843138, + 131: 130.48627450980393, + 132: 131.48235294117646, + 133: 132.478431372549, + 134: 133.47450980392156, + 135: 134.47058823529412, + 136: 135.46666666666667, + 137: 136.46274509803922, + 138: 137.45882352941177, + 139: 138.45490196078433, + 140: 139.45098039215685, + 141: 140.4470588235294, + 142: 141.44313725490196, + 143: 142.4392156862745, + 144: 143.43529411764706, + 145: 144.4313725490196, + 146: 145.42745098039217, + 147: 146.42352941176472, + 148: 147.41960784313724, + 149: 148.4156862745098, + 150: 149.41176470588235, + 151: 150.4078431372549, + 152: 151.40392156862745, + 153: 152.4, + 154: 153.39607843137256, + 155: 154.3921568627451, + 156: 155.38823529411764, + 157: 156.3843137254902, + 158: 157.38039215686274, + 159: 158.3764705882353, + 160: 159.37254901960785, + 161: 160.3686274509804, + 162: 161.36470588235295, + 163: 162.3607843137255, + 164: 163.35686274509803, + 165: 164.35294117647058, + 166: 165.34901960784313, + 167: 166.34509803921569, + 168: 167.34117647058824, + 169: 168.3372549019608, + 170: 169.33333333333334, + 171: 170.3294117647059, + 172: 171.32549019607842, + 173: 172.32156862745097, + 174: 173.31764705882352, + 175: 174.31372549019608, + 176: 175.30980392156863, + 177: 176.30588235294118, + 178: 177.30196078431374, + 179: 178.2980392156863, + 180: 179.2941176470588, + 181: 180.29019607843136, + 182: 181.28627450980392, + 183: 182.28235294117647, + 184: 183.27843137254902, + 185: 184.27450980392157, + 186: 185.27058823529413, + 187: 186.26666666666668, + 188: 187.2627450980392, + 189: 188.25882352941176, + 190: 189.2549019607843, + 191: 190.25098039215686, + 192: 191.24705882352941, + 193: 192.24313725490197, + 194: 193.23921568627452, + 195: 194.23529411764707, + 196: 195.2313725490196, + 197: 196.22745098039215, + 198: 197.2235294117647, + 199: 198.21960784313725, + 200: 199.2156862745098, + 201: 200.21176470588236, + 202: 201.2078431372549, + 203: 202.20392156862746, + 204: 203.2, + 205: 204.19607843137254, + 206: 205.1921568627451, + 207: 206.18823529411765, + 208: 207.1843137254902, + 209: 208.18039215686275, + 210: 209.1764705882353, + 211: 210.17254901960786, + 212: 211.16862745098038, + 213: 212.16470588235293, + 214: 213.1607843137255, + 215: 214.15686274509804, + 216: 215.1529411764706, + 217: 216.14901960784314, + 218: 217.1450980392157, + 219: 218.14117647058825, + 220: 219.13725490196077, + 221: 220.13333333333333, + 222: 221.12941176470588, + 223: 222.12549019607843, + 224: 223.12156862745098, + 225: 224.11764705882354, + 226: 225.1137254901961, + 227: 226.10980392156864, + 228: 227.10588235294117, + 229: 228.10196078431372, + 230: 229.09803921568627, + 231: 230.09411764705882, + 232: 231.09019607843138, + 233: 232.08627450980393, + 234: 233.08235294117648, + 235: 234.07843137254903, + 236: 235.07450980392156, + 237: 236.0705882352941, + 238: 237.06666666666666, + 239: 238.06274509803922, + 240: 239.05882352941177, + 241: 240.05490196078432, + 242: 241.05098039215687, + 243: 242.04705882352943, + 244: 243.04313725490195, + 245: 244.0392156862745, + 246: 245.03529411764706, + 247: 246.0313725490196, + 248: 247.02745098039216, + 249: 248.0235294117647, + 250: 249.01960784313727, + 251: 250.01568627450982, + 252: 251.01176470588234, + 253: 252.0078431372549, + 254: 253.00392156862745, + 255: 254.0, + }) +# --- +# name: test_brightness_to_254_range.1 + dict({ + 0.996078431372549: 1, + 1.992156862745098: 2, + 2.988235294117647: 3, + 3.984313725490196: 4, + 4.980392156862745: 5, + 5.976470588235294: 6, + 6.972549019607843: 7, + 7.968627450980392: 8, + 8.964705882352941: 9, + 9.96078431372549: 10, + 10.95686274509804: 11, + 11.952941176470588: 12, + 12.949019607843137: 13, + 13.945098039215686: 14, + 14.941176470588236: 15, + 15.937254901960785: 16, + 16.933333333333334: 17, + 17.929411764705883: 18, + 18.92549019607843: 19, + 19.92156862745098: 20, + 20.91764705882353: 21, + 21.91372549019608: 22, + 22.909803921568628: 23, + 23.905882352941177: 24, + 24.901960784313726: 25, + 25.898039215686275: 26, + 26.894117647058824: 27, + 27.890196078431373: 28, + 28.886274509803922: 29, + 29.88235294117647: 30, + 30.87843137254902: 31, + 31.87450980392157: 32, + 32.870588235294115: 33, + 33.86666666666667: 34, + 34.86274509803921: 35, + 35.858823529411765: 36, + 36.85490196078431: 37, + 37.85098039215686: 38, + 38.84705882352941: 39, + 39.84313725490196: 40, + 40.83921568627451: 41, + 41.83529411764706: 42, + 42.831372549019605: 43, + 43.82745098039216: 44, + 44.8235294117647: 45, + 45.819607843137256: 46, + 46.8156862745098: 47, + 47.811764705882354: 48, + 48.8078431372549: 49, + 49.80392156862745: 50, + 50.8: 51, + 51.79607843137255: 52, + 52.792156862745095: 53, + 53.78823529411765: 54, + 54.78431372549019: 55, + 55.780392156862746: 56, + 56.77647058823529: 57, + 57.772549019607844: 58, + 58.76862745098039: 59, + 59.76470588235294: 60, + 60.76078431372549: 61, + 61.75686274509804: 62, + 62.752941176470586: 63, + 63.74901960784314: 64, + 64.74509803921569: 65, + 65.74117647058823: 66, + 66.73725490196078: 67, + 67.73333333333333: 68, + 68.72941176470589: 69, + 69.72549019607843: 70, + 70.72156862745098: 71, + 71.71764705882353: 72, + 72.71372549019608: 73, + 73.70980392156862: 74, + 74.70588235294117: 75, + 75.70196078431373: 76, + 76.69803921568628: 77, + 77.69411764705882: 78, + 78.69019607843137: 79, + 79.68627450980392: 80, + 80.68235294117648: 81, + 81.67843137254901: 82, + 82.67450980392157: 83, + 83.67058823529412: 84, + 84.66666666666667: 85, + 85.66274509803921: 86, + 86.65882352941176: 87, + 87.65490196078431: 88, + 88.65098039215687: 89, + 89.6470588235294: 90, + 90.64313725490196: 91, + 91.63921568627451: 92, + 92.63529411764706: 93, + 93.6313725490196: 94, + 94.62745098039215: 95, + 95.62352941176471: 96, + 96.61960784313726: 97, + 97.6156862745098: 98, + 98.61176470588235: 99, + 99.6078431372549: 100, + 100.60392156862746: 101, + 101.6: 102, + 102.59607843137255: 103, + 103.5921568627451: 104, + 104.58823529411765: 105, + 105.58431372549019: 106, + 106.58039215686274: 107, + 107.5764705882353: 108, + 108.57254901960785: 109, + 109.56862745098039: 110, + 110.56470588235294: 111, + 111.56078431372549: 112, + 112.55686274509804: 113, + 113.55294117647058: 114, + 114.54901960784314: 115, + 115.54509803921569: 116, + 116.54117647058824: 117, + 117.53725490196078: 118, + 118.53333333333333: 119, + 119.52941176470588: 120, + 120.52549019607844: 121, + 121.52156862745097: 122, + 122.51764705882353: 123, + 123.51372549019608: 124, + 124.50980392156863: 125, + 125.50588235294117: 126, + 126.50196078431372: 127, + 127.49803921568628: 128, + 128.49411764705883: 129, + 129.49019607843138: 130, + 130.48627450980393: 131, + 131.48235294117646: 132, + 132.478431372549: 133, + 133.47450980392156: 134, + 134.47058823529412: 135, + 135.46666666666667: 136, + 136.46274509803922: 137, + 137.45882352941177: 138, + 138.45490196078433: 139, + 139.45098039215685: 140, + 140.4470588235294: 141, + 141.44313725490196: 142, + 142.4392156862745: 143, + 143.43529411764706: 144, + 144.4313725490196: 145, + 145.42745098039217: 146, + 146.42352941176472: 147, + 147.41960784313724: 148, + 148.4156862745098: 149, + 149.41176470588235: 150, + 150.4078431372549: 151, + 151.40392156862745: 152, + 152.4: 153, + 153.39607843137256: 154, + 154.3921568627451: 155, + 155.38823529411764: 156, + 156.3843137254902: 157, + 157.38039215686274: 158, + 158.3764705882353: 159, + 159.37254901960785: 160, + 160.3686274509804: 161, + 161.36470588235295: 162, + 162.3607843137255: 163, + 163.35686274509803: 164, + 164.35294117647058: 165, + 165.34901960784313: 166, + 166.34509803921569: 167, + 167.34117647058824: 168, + 168.3372549019608: 169, + 169.33333333333334: 170, + 170.3294117647059: 171, + 171.32549019607842: 172, + 172.32156862745097: 173, + 173.31764705882352: 174, + 174.31372549019608: 175, + 175.30980392156863: 176, + 176.30588235294118: 177, + 177.30196078431374: 178, + 178.2980392156863: 179, + 179.2941176470588: 180, + 180.29019607843136: 181, + 181.28627450980392: 182, + 182.28235294117647: 183, + 183.27843137254902: 184, + 184.27450980392157: 185, + 185.27058823529413: 186, + 186.26666666666668: 187, + 187.2627450980392: 188, + 188.25882352941176: 189, + 189.2549019607843: 190, + 190.25098039215686: 191, + 191.24705882352941: 192, + 192.24313725490197: 193, + 193.23921568627452: 194, + 194.23529411764707: 195, + 195.2313725490196: 196, + 196.22745098039215: 197, + 197.2235294117647: 198, + 198.21960784313725: 199, + 199.2156862745098: 200, + 200.21176470588236: 201, + 201.2078431372549: 202, + 202.20392156862746: 203, + 203.2: 204, + 204.19607843137254: 205, + 205.1921568627451: 206, + 206.18823529411765: 207, + 207.1843137254902: 208, + 208.18039215686275: 209, + 209.1764705882353: 210, + 210.17254901960786: 211, + 211.16862745098038: 212, + 212.16470588235293: 213, + 213.1607843137255: 214, + 214.15686274509804: 215, + 215.1529411764706: 216, + 216.14901960784314: 217, + 217.1450980392157: 218, + 218.14117647058825: 219, + 219.13725490196077: 220, + 220.13333333333333: 221, + 221.12941176470588: 222, + 222.12549019607843: 223, + 223.12156862745098: 224, + 224.11764705882354: 225, + 225.1137254901961: 226, + 226.10980392156864: 227, + 227.10588235294117: 228, + 228.10196078431372: 229, + 229.09803921568627: 230, + 230.09411764705882: 231, + 231.09019607843138: 232, + 232.08627450980393: 233, + 233.08235294117648: 234, + 234.07843137254903: 235, + 235.07450980392156: 236, + 236.0705882352941: 237, + 237.06666666666666: 238, + 238.06274509803922: 239, + 239.05882352941177: 240, + 240.05490196078432: 241, + 241.05098039215687: 242, + 242.04705882352943: 243, + 243.04313725490195: 244, + 244.0392156862745: 245, + 245.03529411764706: 246, + 246.0313725490196: 247, + 247.02745098039216: 248, + 248.0235294117647: 249, + 249.01960784313727: 250, + 250.01568627450982: 251, + 251.01176470588234: 252, + 252.0078431372549: 253, + 253.00392156862745: 254, + 254.0: 255, + }) +# --- diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index bfdc3c3e949..ada0269ac0e 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,5 +1,4 @@ """Test aiohttp request helper.""" -import sys from aiohttp import web @@ -50,22 +49,11 @@ def test_serialize_text() -> None: def test_serialize_body_str() -> None: """Test serializing a response with a str as body.""" response = web.Response(status=201, body="Hello") - # TODO: Remove version check with aiohttp 3.9.0 - if sys.version_info >= (3, 12): - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": {"Content-Type": "text/plain; charset=utf-8"}, - } - else: - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": { - "Content-Length": "5", - "Content-Type": "text/plain; charset=utf-8", - }, - } + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + } def test_serialize_body_None() -> None: diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 7c5e959aabc..5dd20d8d887 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -1,5 +1,8 @@ """Test Home Assistant color util methods.""" +import math + import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol import homeassistant.util.color as color_util @@ -270,6 +273,15 @@ def test_color_rgbw_to_rgb() -> None: assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127) +def test_color_xy_to_temperature() -> None: + """Test color_xy_to_temperature.""" + assert color_util.color_xy_to_temperature(0.5119, 0.4147) == 2136 + assert color_util.color_xy_to_temperature(0.368, 0.3686) == 4302 + assert color_util.color_xy_to_temperature(0.4448, 0.4066) == 2893 + assert color_util.color_xy_to_temperature(0.1, 0.8) == 8645 + assert color_util.color_xy_to_temperature(0.5, 0.4) == 2140 + + def test_color_rgb_to_hex() -> None: """Test color_rgb_to_hex.""" assert color_util.color_rgb_to_hex(255, 255, 255) == "ffffff" @@ -578,3 +590,137 @@ def test_white_levels_to_color_temperature() -> None: 2000, 0, ) + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (530, 255), # test min==255 clamp + (511, 255), + (255, 127), + (49, 24), + (1, 1), + (0, 1), # test max==1 clamp + ], +) +async def test_ranged_value_to_brightness_large(value: float, brightness: int) -> None: + """Test a large scale and clamping and convert a single value to a brightness.""" + scale = (1, 511) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("brightness", "value", "math_ceil"), + [ + (255, 511.0, 511), + (127, 254.49803921568628, 255), + (24, 48.09411764705882, 49), + ], +) +async def test_brightness_to_ranged_value_large( + brightness: int, value: float, math_ceil: int +) -> None: + """Test a large scale and convert a brightness to a single value.""" + scale = (1, 511) + + assert color_util.brightness_to_value(scale, brightness) == value + + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == math_ceil + + +@pytest.mark.parametrize( + ("scale", "value", "brightness"), + [ + ((1, 4), 1, 64), + ((1, 4), 2, 128), + ((1, 4), 3, 191), + ((1, 4), 4, 255), + ((1, 6), 1, 42), + ((1, 6), 2, 85), + ((1, 6), 3, 128), + ((1, 6), 4, 170), + ((1, 6), 5, 212), + ((1, 6), 6, 255), + ], +) +async def test_ranged_value_to_brightness_small( + scale: tuple[float, float], value: float, brightness: int +) -> None: + """Test a small scale and convert a single value to a brightness.""" + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("scale", "brightness", "value"), + [ + ((1, 4), 63, 1), + ((1, 4), 127, 2), + ((1, 4), 191, 3), + ((1, 4), 255, 4), + ((1, 6), 42, 1), + ((1, 6), 85, 2), + ((1, 6), 127, 3), + ((1, 6), 170, 4), + ((1, 6), 212, 5), + ((1, 6), 255, 6), + ], +) +async def test_brightness_to_ranged_value_small( + scale: tuple[float, float], brightness: int, value: float +) -> None: + """Test a small scale and convert a brightness to a single value.""" + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == value + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (101, 2), + (139, 64), + (178, 128), + (217, 192), + (255, 255), + ], +) +async def test_ranged_value_to_brightness_starting_high( + value: float, brightness: int +) -> None: + """Test a range that does not start with 1.""" + scale = (101, 255) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (0, 64), + (1, 128), + (2, 191), + (3, 255), + ], +) +async def test_ranged_value_to_brightness_starting_zero( + value: float, brightness: int +) -> None: + """Test a range that starts with 0.""" + scale = (0, 3) + + assert color_util.value_to_brightness(scale, value) == brightness + + +async def test_brightness_to_254_range(snapshot: SnapshotAssertion) -> None: + """Test brightness scaling to a 254 range and back.""" + brightness_range = range(1, 256) # (1..255) + scale = (1, 254) + scaled_values = { + brightness: color_util.brightness_to_value(scale, brightness) + for brightness in brightness_range + } + assert scaled_values == snapshot + restored_values = {} + for expected_brightness, value in scaled_values.items(): + restored_values[value] = color_util.value_to_brightness(scale, value) + assert color_util.value_to_brightness(scale, value) == expected_brightness + assert restored_values == snapshot diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 28695a94400..a973135d831 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import time import pytest @@ -737,8 +736,3 @@ def test_find_next_time_expression_tenth_second_pattern_does_not_drift_entering_ assert (next_target - prev_target).total_seconds() == 60 assert next_target.second == 10 prev_target = next_target - - -def test_monotonic_time_coarse() -> None: - """Test monotonic time coarse.""" - assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 076864c65c4..d7731a44b7d 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -88,6 +88,10 @@ async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None iexecutor.shutdown() finish = time.monotonic() - assert finish - start < 1.3 + # Idealy execution time (finish - start) should be < 1.2 sec. + # CI tests might not run in an ideal environment and timing might + # not be accurate, so we let this test pass + # if the duration is below 3 seconds. + assert finish - start < 3.0 iexecutor.shutdown() diff --git a/tests/util/test_json.py b/tests/util/test_json.py index b3bccf71b58..ff0f1ed8392 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -1,10 +1,12 @@ """Test Home Assistant json utility functions.""" from pathlib import Path +import orjson import pytest from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import ( + json_loads, json_loads_array, json_loads_object, load_json, @@ -153,3 +155,20 @@ async def test_deprecated_save_json( save_json(fname, TEST_JSON_A) assert "uses save_json from homeassistant.util.json" in caplog.text assert "should be updated to use homeassistant.helpers.json module" in caplog.text + + +async def test_loading_derived_class(): + """Test loading data from classes derived from str.""" + + class MyStr(str): + pass + + class MyBytes(bytes): + pass + + assert json_loads('"abc"') == "abc" + assert json_loads(MyStr('"abc"')) == "abc" + + assert json_loads(b'"abc"') == "abc" + with pytest.raises(orjson.JSONDecodeError): + assert json_loads(MyBytes(b'"abc"')) == "abc" diff --git a/tests/util/test_scaling.py b/tests/util/test_scaling.py new file mode 100644 index 00000000000..5fef6cf806b --- /dev/null +++ b/tests/util/test_scaling.py @@ -0,0 +1,249 @@ +"""Test Home Assistant scaling utils.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + scale_ranged_value_to_int_range, + scale_to_ranged_value, +) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (255, 100), + (127, 49), + (10, 3), + (1, 0), + ], +) +async def test_ranged_value_to_int_range_large( + input_val: float, output_val: int +) -> None: + """Test a large range of low and high values convert a single value to a percentage.""" + source_range = (1, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val", "math_ceil"), + [ + (100, 255, 255), + (50, 127.5, 128), + (4, 10.2, 11), + ], +) +async def test_scale_to_ranged_value_large( + input_val: float, output_val: float, math_ceil: int +) -> None: + """Test a large range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 255) + + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_val + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == math_ceil + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 16), + (2, 33), + (3, 50), + (4, 66), + (5, 83), + (6, 100), + ], +) +async def test_scale_ranged_value_to_int_range_small( + input_val: float, output_val: int +) -> None: + """Test a small range of low and high values convert a single value to a percentage.""" + source_range = (1, 6) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (16, 1), + (33, 2), + (50, 3), + (66, 4), + (83, 5), + (100, 6), + ], +) +async def test_scale_to_ranged_value_small(input_val: float, output_val: int) -> None: + """Test a small range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 6) + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 25), + (2, 50), + (3, 75), + (4, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_at_one( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 1.""" + source_range = (1, 4) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 0), + (139, 25), + (178, 50), + (217, 75), + (255, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high( + input_val: float, output_val: int +) -> None: + """Test a range that does not start with 1.""" + source_range = (101, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 25, 25.0), + (1.0, 50, 50.0), + (2.0, 75, 75.0), + (3.0, 100, 100.0), + ], +) +async def test_scale_ranged_value_to_scaled_range_starting_zero( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a range that starts with 0.""" + source_range = (0, 3) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range( + dest_range, source_range, output_float + ) == int(input_val) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 100), + (139, 125), + (178, 150), + (217, 175), + (255, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high_with_offset( + input_val: float, output_val: int +) -> None: + """Test a ranges that do not start with 1.""" + source_range = (101, 255) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (0, 125), + (1, 150), + (2, 175), + (3, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_offset( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 0 and an other starting high.""" + source_range = (0, 3) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 1, 1.0), + (1.0, 3, 3.0), + (2.0, 5, 5.0), + (3.0, 7, 7.0), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_zero_offset( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a ranges that start with 0. + + In case a range starts with 0, this means value 0 is the first value, + and the values shift -1. + """ + source_range = (0, 3) + dest_range = (0, 7) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range(dest_range, source_range, output_int) == int( + input_val + ) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index 4d43859cc44..4a88e061cbc 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -51,3 +51,12 @@ def test_no_verify_ssl_context(mock_sslcontext) -> None: mock_sslcontext.set_ciphers.assert_called_with( SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] ) + + +def test_ssl_context_caching() -> None: + """Test that SSLContext instances are cached correctly.""" + + assert client_context() is client_context(SSLCipherList.PYTHON_DEFAULT) + assert create_no_verify_ssl_context() is create_no_verify_ssl_context( + SSLCipherList.PYTHON_DEFAULT + ) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 990956ec908..1e31d8c6955 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,13 +1,15 @@ """Test Home Assistant yaml loader.""" +from collections.abc import Generator import importlib import io import os import pathlib from typing import Any import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest +import voluptuous as vol import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file @@ -132,6 +134,7 @@ def test_include_yaml( [ ({"/test/one.yaml": "one", "/test/two.yaml": "two"}, ["one", "two"]), ({"/test/one.yaml": "1", "/test/two.yaml": "2"}, [1, 2]), + ({"/test/one.yaml": "1", "/test/two.yaml": None}, [1]), ], ) def test_include_dir_list( @@ -188,6 +191,10 @@ def test_include_dir_list_recursive( {"/test/first.yaml": "1", "/test/second.yaml": "2"}, {"first": 1, "second": 2}, ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": None}, + {"first": 1}, + ), ], ) def test_include_dir_named( @@ -247,6 +254,10 @@ def test_include_dir_named_recursive( {"/test/first.yaml": "- 1", "/test/second.yaml": "- 2\n- 3"}, [1, 2, 3], ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": None}, + [1], + ), ], ) def test_include_dir_merge_list( @@ -309,6 +320,13 @@ def test_include_dir_merge_list_recursive( }, {"key1": 1, "key2": 2, "key3": 3}, ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": None, + }, + {"key1": 1}, + ), ], ) def test_include_dir_merge_named( @@ -584,6 +602,58 @@ async def test_loading_actual_file_with_syntax_error( await hass.async_add_executor_job(load_yaml_config_file, fixture_path) +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + +@pytest.mark.parametrize( + ("loader_class", "message"), + [ + (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), + ( + yaml.loader.SafeLineLoader, + "'SafeLineLoader' instead of 'PythonSafeLoader'", + ), + ], +) +async def test_deprecated_loaders( + hass: HomeAssistant, + mock_integration_frame: Mock, + caplog: pytest.LogCaptureFixture, + loader_class, + message: str, +) -> None: + """Test instantiating the deprecated yaml loaders logs a warning.""" + with pytest.raises(TypeError), patch( + "homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set() + ): + loader_class() + assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text + + def test_string_annotated(try_both_loaders) -> None: """Test strings are annotated with file + line.""" conf = ( @@ -615,3 +685,37 @@ def test_string_annotated(try_both_loaders) -> None: getattr(value, "__config_file__", None) == expected_annotations[key][1][0] ) assert getattr(value, "__line__", None) == expected_annotations[key][1][1] + + +def test_string_used_as_vol_schema(try_both_loaders) -> None: + """Test the subclassed strings can be used in voluptuous schemas.""" + conf = "wanted_data:\n key_1: value_1\n key_2: value_2\n" + with io.StringIO(conf) as file: + doc = yaml_loader.parse_yaml(file) + + # Test using the subclassed strings in a schema + schema = vol.Schema( + {vol.Required(key): value for key, value in doc["wanted_data"].items()}, + ) + # Test using the subclassed strings when validating a schema + schema(doc["wanted_data"]) + schema({"key_1": "value_1", "key_2": "value_2"}) + with pytest.raises(vol.Invalid): + schema({"key_1": "value_2", "key_2": "value_1"}) + + +@pytest.mark.parametrize( + ("hass_config_yaml", "expected_data"), [("", {}), ("bla:", {"bla": None})] +) +def test_load_yaml_dict( + try_both_loaders, mock_hass_config_yaml: None, expected_data: Any +) -> None: + """Test item without a key.""" + assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + + +@pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) +def test_load_yaml_dict_fail(try_both_loaders, mock_hass_config_yaml: None) -> None: + """Test item without a key.""" + with pytest.raises(yaml_loader.YamlTypeError): + yaml_loader.load_yaml_dict(YAML_CONFIG_FILE)