Compare commits

...

28 Commits

Author SHA1 Message Date
Robert Resch
6844253260 Replace pre-commit by prek 2026-01-07 14:51:54 +01:00
Robert Resch
9281ab018c Constraint aiomqtt>=2.5.0 to fix blocking call (#160410)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-07 14:21:49 +01:00
Andres Ruiz
80baf86e23 Add codeowners and integration_type for waterfurnace (#160397) 2026-01-07 13:12:58 +01:00
Simone Chemelli
db497b23fe Small cleanup for Vodafone Station tests (#160415) 2026-01-07 12:50:12 +01:00
cdnninja
a2fb8f5a72 Add Vesync Air Fryer Sensors (#160170)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-07 12:41:34 +01:00
hanwg
6953bd4599 Fix schema validation error in Telegram (#160367) 2026-01-07 12:27:17 +01:00
Xiangxuan Qu
225be65f71 Fix IndexError in Israel Rail sensor when no departures available (#160351)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 12:22:39 +01:00
momala454
7b0463f763 Add additional lens modes 4 to 10 to JVC projector remote (#159657)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 12:22:19 +01:00
Luke Lashley
4d305b657a Bump python-roborock to 4.2.1 (#160398) 2026-01-07 11:23:40 +01:00
Paul Tarjan
d5a553c8c7 Fix Ring integration log flooding for accounts without subscription (#158012)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-01-07 11:14:05 +01:00
Ivan Dlugos
9169b68254 Bump sentry-sdk to 2.48.0 (#159415)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 11:05:38 +01:00
Colin
fde9bd95d5 Replace openevse backend library (#160325) 2026-01-07 10:25:15 +01:00
Marc Mueller
e4db8ff86e Update guppy3 to 3.1.6 (#160356) 2026-01-07 10:11:01 +01:00
Erik Montnemery
a084e51345 Add test helpers for numerical state triggers (#160308)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-07 08:53:35 +01:00
Luke Lashley
00381e6dfd Remove q7 total cleaning time for Roborock (#160399) 2026-01-06 20:27:09 -08:00
Michael Hansen
b6d493696a Bump intents to 2026.1.6 (#160389) 2026-01-06 17:11:54 -06:00
Artem Draft
5f0500c3cd Add SSL support in Bravia TV (#160373)
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
2026-01-06 23:59:47 +01:00
dontinelli
c61a63cc6f Bump solarlog_cli to 0.7.0 (#160382) 2026-01-06 23:59:16 +01:00
Raphael Hehl
5445a4f40f Bump uiprotect to 8.0.0 (#160384)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-06 23:57:19 +01:00
Daniel Hjelseth Høyer
2888cacc3f Bump pyTibber to 0.34.1 (#160380)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-06 23:56:26 +01:00
TheJulianJES
16f3e6d2c9 Bump ZHA to 0.0.83 (#160342) 2026-01-06 12:11:40 -05:00
Bram Kragten
7a872970fa Update frontend to 20251229.1 (#160372) 2026-01-06 17:53:56 +01:00
Bram Kragten
4f5ca986ce Fix number or entity choose schema (#160358) 2026-01-06 17:23:24 +01:00
Artem Draft
b58e058da5 Bump pybravia to 0.4.1 (#160368) 2026-01-06 16:42:58 +01:00
epenet
badebe0c7f Refactor Tuya event platform to use DeviceWrapper (#160366) 2026-01-06 16:09:13 +01:00
mettolen
7817ec1a52 Update Saunum integration to gold quality tier (#159783) 2026-01-06 16:07:28 +01:00
epenet
c773998946 Remove default in Tuya DeviceWrapper options (#160303) 2026-01-06 13:06:53 +01:00
Mika
2bc9397103 Fix missing state class to solaredge (#160336) 2026-01-06 12:36:49 +01:00
88 changed files with 2087 additions and 930 deletions

View File

@@ -847,8 +847,8 @@ rules:
## Development Commands
### Code Quality & Linting
- **Run all linters on all files**: `pre-commit run --all-files`
- **Run linters on staged files only**: `pre-commit run`
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`

View File

@@ -59,7 +59,6 @@ env:
# 15 is the latest version
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
PRE_COMMIT_CACHE: ~/.cache/pre-commit
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
@@ -83,7 +82,6 @@ jobs:
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
pre-commit_cache_key: ${{ steps.generate_pre-commit_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
@@ -111,11 +109,6 @@ jobs:
hashFiles('requirements_all.txt') }}-${{
hashFiles('homeassistant/package_constraints.txt') }}-${{
hashFiles('script/gen_requirements_all.py') }}" >> $GITHUB_OUTPUT
- name: Generate partial pre-commit restore key
id: generate_pre-commit_cache_key
run: >-
echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
@@ -244,8 +237,8 @@ jobs:
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
pre-commit:
name: Prepare pre-commit base
prek:
name: Run prek checks
runs-on: *runs-on-ubuntu
needs: [info]
if: |
@@ -254,147 +247,23 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-pre-commit-venv >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
key: &key-pre-commit-env >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Install pre-commit dependencies
if: steps.cache-precommit.outputs.cache-hit != 'true'
run: |
. venv/bin/activate
pre-commit install-hooks
lint-ruff-format:
name: Check ruff-format
runs-on: *runs-on-ubuntu
needs: &needs-pre-commit
- info
- pre-commit
steps:
- *checkout
- *setup-python-default
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
fail-on-cache-miss: true
key: *key-pre-commit-venv
- &cache-restore-pre-commit-env
name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache-restore
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
key: *key-pre-commit-env
- name: Run ruff-format
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-ruff:
name: Check ruff
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Run ruff
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-other:
name: Check other linters
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Register yamllint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
- name: Run yamllint
run: |
. venv/bin/activate
pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure
- name: Register check-json problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-json.json"
- name: Run check-json
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-json --all-files --show-diff-on-failure
- name: Run prettier (fully)
if: needs.info.outputs.test_full_suite == 'true'
run: |
. venv/bin/activate
pre-commit run --hook-stage manual prettier --all-files --show-diff-on-failure
- name: Run prettier (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
run: |
. venv/bin/activate
shopt -s globstar
pre-commit run --hook-stage manual prettier --show-diff-on-failure --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
- name: Register check executables problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
- name: Run executables check
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-executables-have-shebangs --all-files --show-diff-on-failure
- name: Register codespell problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run codespell
run: |
. venv/bin/activate
pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files
- name: Run prek
uses: j178/prek-action@91fd7d7cf70ae1dee9f4f44e7dfa5d1073fe6623 # v1.0.11
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github
lint-hadolint:
name: Check ${{ matrix.file }}
@@ -434,7 +303,7 @@ jobs:
- &setup-python-matrix
name: Set up Python ${{ matrix.python-version }}
id: python
uses: *actions-setup-python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -447,7 +316,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: *actions-cache
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-python-venv >-
@@ -562,7 +431,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: *actions-cache-restore
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: *path-apt-cache
fail-on-cache-miss: true
@@ -579,7 +448,13 @@ jobs:
-o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
libturbojpeg
- *checkout
- *setup-python-default
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: *actions-setup-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- &cache-restore-python-default
name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv

View File

@@ -46,7 +46,7 @@ repos:
# Run `python-typing-update` hook manually from time to time
# to update python typing syntax.
# Will require manual work, before submitting changes!
# pre-commit run --hook-stage manual python-typing-update --all-files
# prek run --hook-stage manual python-typing-update --all-files
- id: python-typing-update
stages: [manual]
args:

6
.vscode/tasks.json vendored
View File

@@ -45,7 +45,7 @@
{
"label": "Ruff",
"type": "shell",
"command": "pre-commit run ruff-check --all-files",
"command": "prek run ruff-check --all-files",
"group": {
"kind": "test",
"isDefault": true
@@ -57,9 +57,9 @@
"problemMatcher": []
},
{
"label": "Pre-commit",
"label": "Prek",
"type": "shell",
"command": "pre-commit run --show-diff-on-failure",
"command": "prek run --show-diff-on-failure",
"group": {
"kind": "test",
"isDefault": true

1
CODEOWNERS generated
View File

@@ -1803,6 +1803,7 @@ build.json @home-assistant/supervisor
/tests/components/waqi/ @joostlek
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai

View File

@@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_USE_SSL
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
PLATFORMS: Final[list[Platform]] = [
@@ -26,11 +27,12 @@ async def async_setup_entry(
"""Set up a config entry."""
host = config_entry.data[CONF_HOST]
mac = config_entry.data[CONF_MAC]
ssl = config_entry.data.get(CONF_USE_SSL, False)
session = async_create_clientsession(
hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False)
)
client = BraviaClient(host, mac, session=session)
client = BraviaClient(host, mac, session=session, ssl=ssl)
coordinator = BraviaTVCoordinator(
hass=hass,
config_entry=config_entry,

View File

@@ -28,6 +28,7 @@ from .const import (
ATTR_MODEL,
CONF_NICKNAME,
CONF_USE_PSK,
CONF_USE_SSL,
DOMAIN,
NICKNAME_PREFIX,
)
@@ -46,11 +47,12 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
def create_client(self) -> None:
"""Create Bravia TV client from config."""
host = self.device_config[CONF_HOST]
ssl = self.device_config[CONF_USE_SSL]
session = async_create_clientsession(
self.hass,
cookie_jar=CookieJar(unsafe=True, quote_cookie=False),
)
self.client = BraviaClient(host=host, session=session)
self.client = BraviaClient(host=host, session=session, ssl=ssl)
async def gen_instance_ids(self) -> tuple[str, str]:
"""Generate client_id and nickname."""
@@ -123,10 +125,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle authorize step."""
self.create_client()
if user_input is not None:
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL]
self.create_client()
if user_input[CONF_USE_PSK]:
return await self.async_step_psk()
return await self.async_step_pin()
@@ -136,6 +138,7 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(CONF_USE_PSK, default=False): bool,
vol.Required(CONF_USE_SSL, default=False): bool,
}
),
)

View File

@@ -12,6 +12,7 @@ ATTR_MODEL: Final = "model"
CONF_NICKNAME: Final = "nickname"
CONF_USE_PSK: Final = "use_psk"
CONF_USE_SSL: Final = "use_ssl"
DOMAIN: Final = "braviatv"
LEGACY_CLIENT_ID: Final = "HomeAssistant"

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pybravia"],
"requirements": ["pybravia==0.3.4"],
"requirements": ["pybravia==0.4.1"],
"ssdp": [
{
"manufacturer": "Sony Corporation",

View File

@@ -15,9 +15,10 @@
"step": {
"authorize": {
"data": {
"use_psk": "Use PSK authentication"
"use_psk": "Use PSK authentication",
"use_ssl": "Use SSL connection"
},
"description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
"description": "Make sure that «Control remotely» is enabled on your TV. Go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended, as it is more stable. \n\nUse an SSL connection only if your TV supports this connection type.",
"title": "Authorize Sony Bravia TV"
},
"confirm": {

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.6"]
}

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251229.0"]
"requirements": ["home-assistant-frontend==20251229.1"]
}

View File

@@ -116,6 +116,8 @@ class IsraelRailEntitySensor(
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
if self.entity_description.index >= len(self.coordinator.data):
return None
return self.entity_description.value_fn(
self.coordinator.data[self.entity_description.index]
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==1.1.2"]
"requirements": ["pyjvcprojector==1.1.3"]
}

View File

@@ -41,6 +41,13 @@ COMMANDS = {
"mode_1": const.REMOTE_MODE_1,
"mode_2": const.REMOTE_MODE_2,
"mode_3": const.REMOTE_MODE_3,
"mode_4": const.REMOTE_MODE_4,
"mode_5": const.REMOTE_MODE_5,
"mode_6": const.REMOTE_MODE_6,
"mode_7": const.REMOTE_MODE_7,
"mode_8": const.REMOTE_MODE_8,
"mode_9": const.REMOTE_MODE_9,
"mode_10": const.REMOTE_MODE_10,
"lens_ap": const.REMOTE_LENS_AP,
"gamma": const.REMOTE_GAMMA,
"color_temp": const.REMOTE_COLOR_TEMP,

View File

@@ -2,23 +2,23 @@
from __future__ import annotations
import openevsewifi
from openevsehttp.__main__ import OpenEVSE
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
type OpenEVSEConfigEntry = ConfigEntry[openevsewifi.Charger]
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
"""Set up openevse from a config entry."""
entry.runtime_data = openevsewifi.Charger(entry.data[CONF_HOST])
entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
try:
await hass.async_add_executor_job(entry.runtime_data.getStatus)
except AttributeError as ex:
await entry.runtime_data.test_and_get()
except TimeoutError as ex:
raise ConfigEntryError("Unable to connect to charger") from ex
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])

View File

@@ -2,7 +2,7 @@
from typing import Any
import openevsewifi
from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -20,13 +20,13 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
async def check_status(self, host: str) -> bool:
"""Check if we can connect to the OpenEVSE charger."""
charger = openevsewifi.Charger(host)
charger = OpenEVSE(host)
try:
result = await self.hass.async_add_executor_job(charger.getStatus)
except AttributeError:
await charger.test_and_get()
except TimeoutError:
return False
else:
return result is not None
return True
async def async_step_user(
self, user_input: dict[str, Any] | None = None

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/openevse",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["openevsewifi"],
"loggers": ["openevsehttp"],
"quality_scale": "legacy",
"requirements": ["openevsewifi==1.1.2"]
"requirements": ["python-openevse-http==0.2.1"]
}

View File

@@ -4,8 +4,7 @@ from __future__ import annotations
import logging
import openevsewifi
from requests import RequestException
from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -175,7 +174,7 @@ class OpenEVSESensor(SensorEntity):
def __init__(
self,
host: str,
charger: openevsewifi.Charger,
charger: OpenEVSE,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
@@ -183,25 +182,28 @@ class OpenEVSESensor(SensorEntity):
self.host = host
self.charger = charger
def update(self) -> None:
async def async_update(self) -> None:
"""Get the monitored data from the charger."""
try:
sensor_type = self.entity_description.key
if sensor_type == "status":
self._attr_native_value = self.charger.getStatus()
elif sensor_type == "charge_time":
self._attr_native_value = self.charger.getChargeTimeElapsed() / 60
elif sensor_type == "ambient_temp":
self._attr_native_value = self.charger.getAmbientTemperature()
elif sensor_type == "ir_temp":
self._attr_native_value = self.charger.getIRTemperature()
elif sensor_type == "rtc_temp":
self._attr_native_value = self.charger.getRTCTemperature()
elif sensor_type == "usage_session":
self._attr_native_value = float(self.charger.getUsageSession()) / 1000
elif sensor_type == "usage_total":
self._attr_native_value = float(self.charger.getUsageTotal()) / 1000
else:
self._attr_native_value = "Unknown"
except (RequestException, ValueError, KeyError):
await self.charger.update()
except TimeoutError:
_LOGGER.warning("Could not update status for %s", self.name)
return
sensor_type = self.entity_description.key
if sensor_type == "status":
self._attr_native_value = self.charger.status
elif sensor_type == "charge_time":
self._attr_native_value = self.charger.charge_time_elapsed / 60
elif sensor_type == "ambient_temp":
self._attr_native_value = self.charger.ambient_temperature
elif sensor_type == "ir_temp":
self._attr_native_value = self.charger.ir_temperature
elif sensor_type == "rtc_temp":
self._attr_native_value = self.charger.rtc_temperature
elif sensor_type == "usage_session":
self._attr_native_value = float(self.charger.usage_session) / 1000
elif sensor_type == "usage_total":
self._attr_native_value = float(self.charger.usage_total) / 1000
else:
self._attr_native_value = "Unknown"

View File

@@ -453,10 +453,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall)
# Imports deferred to avoid loading modules
# in memory since usually only one part of this
# integration is used at a time
if sys.version_info >= (3, 14):
raise HomeAssistantError(
"Memory profiling is not supported on Python 3.14. Please use Python 3.13."
)
from guppy import hpy # noqa: PLC0415
start_time = int(time.time() * 1000000)

View File

@@ -7,7 +7,7 @@
"quality_scale": "internal",
"requirements": [
"pyprof2calltree==1.4.5",
"guppy3==3.1.5;python_version<'3.14'",
"guppy3==3.1.6",
"objgraph==3.5.0"
],
"single_config_entry": true

View File

@@ -128,8 +128,9 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self._device = self._get_coordinator_data().get_video_device(
self._device.device_api_id
)
history_data = self._device.last_history
if history_data:
if history_data and self._device.has_subscription:
self._last_event = history_data[0]
# will call async_update to update the attributes and get the
# video url from the api
@@ -154,8 +155,16 @@ class RingCam(RingEntity[RingDoorBell], Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
if self._video_url is None:
if not self._device.has_subscription:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_subscription",
)
return None
key = (width, height)
if not (image := self._images.get(key)) and self._video_url is not None:
if not (image := self._images.get(key)):
image = await ffmpeg.async_get_image(
self.hass,
self._video_url,

View File

@@ -151,6 +151,9 @@
"api_timeout": {
"message": "Timeout communicating with Ring API"
},
"no_subscription": {
"message": "Ring Protect subscription required for snapshots"
},
"sdp_m_line_index_required": {
"message": "Error negotiating stream for {device}"
}

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==4.2.0",
"python-roborock==4.2.1",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -391,15 +391,6 @@ Q7_B01_SENSOR_DESCRIPTIONS = [
translation_key="mop_life_time_left",
entity_category=EntityCategory.DIAGNOSTIC,
),
RoborockSensorDescriptionB01(
key="total_cleaning_time",
value_fn=lambda data: data.real_clean_time,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_unit_of_measurement=UnitOfTime.HOURS,
translation_key="total_cleaning_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
]

View File

@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["pysaunum==0.1.0"]
}

View File

@@ -49,7 +49,7 @@ rules:
status: exempt
comment: Device cannot be discovered and the Modbus TCP API does not provide MAC address or other unique network identifiers needed to update connection information.
docs-data-update: done
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sentry",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["sentry-sdk==1.45.1"]
"requirements": ["sentry-sdk==2.48.0"]
}

View File

@@ -46,7 +46,7 @@ SENSOR_TYPES = [
key="lifetime_energy",
json_key="lifeTimeData",
translation_key="lifetime_energy",
state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -55,6 +55,7 @@ SENSOR_TYPES = [
json_key="lastYearData",
translation_key="energy_this_year",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -63,6 +64,7 @@ SENSOR_TYPES = [
json_key="lastMonthData",
translation_key="energy_this_month",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -71,6 +73,7 @@ SENSOR_TYPES = [
json_key="lastDayData",
translation_key="energy_today",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
@@ -123,24 +126,32 @@ SENSOR_TYPES = [
json_key="LOAD",
translation_key="power_consumption",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="solar_power",
json_key="PV",
translation_key="solar_power",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="grid_power",
json_key="GRID",
translation_key="grid_power",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="storage_power",
json_key="STORAGE",
translation_key="storage_power",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
),
SolarEdgeSensorEntityDescription(
key="purchased_energy",
@@ -194,6 +205,7 @@ SENSOR_TYPES = [
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
]

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["solarlog_cli"],
"quality_scale": "platinum",
"requirements": ["solarlog_cli==0.6.1"]
"requirements": ["solarlog_cli==0.7.0"]
}

View File

@@ -80,10 +80,6 @@ class TelegramNotificationService(BaseNotificationService):
def send_message(self, message="", **kwargs):
"""Send a message to a user."""
service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
if ATTR_TITLE in kwargs:
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
if message:
service_data.update({ATTR_MESSAGE: message})
data = kwargs.get(ATTR_DATA)
# Set message tag
@@ -161,6 +157,12 @@ class TelegramNotificationService(BaseNotificationService):
)
# Send message
if ATTR_TITLE in kwargs:
service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
if message:
service_data.update({ATTR_MESSAGE: message})
_LOGGER.debug(
"TELEGRAM NOTIFIER calling %s.send_message with %s",
TELEGRAM_BOT_DOMAIN,

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.34.0"]
"requirements": ["pyTibber==0.34.1"]
}

View File

@@ -183,13 +183,12 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
self._state_wrapper = state_wrapper
# Determine supported modes
if action_wrapper.options:
if "arm_home" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if "arm_away" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if "trigger" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
if "arm_home" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if "arm_away" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if "trigger" in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
@property
def alarm_state(self) -> AlarmControlPanelState | None:

View File

@@ -368,7 +368,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
# Determine HVAC modes
self._attr_hvac_modes: list[HVACMode] = []
self._hvac_to_tuya = {}
if hvac_mode_wrapper and hvac_mode_wrapper.options is not None:
if hvac_mode_wrapper:
self._attr_hvac_modes = [HVACMode.OFF]
unknown_hvac_modes: list[str] = []
for tuya_mode in hvac_mode_wrapper.options:

View File

@@ -351,7 +351,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._set_position = set_position
self._tilt_position = tilt_position
if instruction_wrapper and instruction_wrapper.options:
if instruction_wrapper:
if "open" in instruction_wrapper.options:
self._attr_supported_features |= CoverEntityFeature.OPEN
if "close" in instruction_wrapper.options:
@@ -424,11 +424,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
if (
self._instruction_wrapper
and (options := self._instruction_wrapper.options)
and "stop" in options
):
if self._instruction_wrapper and "stop" in self._instruction_wrapper.options:
await self._async_send_wrapper_updates(self._instruction_wrapper, "stop")
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:

View File

@@ -21,6 +21,7 @@ from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import (
DeviceWrapper,
DPCodeEnumWrapper,
DPCodeRawWrapper,
DPCodeStringWrapper,
@@ -28,75 +29,58 @@ from .models import (
)
class _DPCodeEventWrapper(DPCodeTypeInformationWrapper):
"""Base class for Tuya event wrappers."""
class _EventEnumWrapper(DPCodeEnumWrapper):
"""Wrapper for event enum DP codes."""
options: list[str]
def read_device_status(self, device: CustomerDevice) -> tuple[str, None] | None:
"""Return the event details."""
if (raw_value := super().read_device_status(device)) is None:
return None
return (raw_value, None)
class _AlarmMessageWrapper(DPCodeStringWrapper):
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
def __init__(self, dpcode: str, type_information: Any) -> None:
"""Init _DPCodeEventWrapper."""
"""Init _AlarmMessageWrapper."""
super().__init__(dpcode, type_information)
self.options = ["triggered"]
def get_event_type(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> str | None:
"""Return the event type."""
if (
updated_status_properties is None
or self.dpcode not in updated_status_properties
):
return None
return "triggered"
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
"""Return the event attributes."""
return None
class _EventEnumWrapper(DPCodeEnumWrapper, _DPCodeEventWrapper):
"""Wrapper for event enum DP codes."""
def get_event_type(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> str | None:
"""Return the triggered event type."""
if (
updated_status_properties is None
or self.dpcode not in updated_status_properties
):
return None
return self.read_device_status(device)
class _AlarmMessageWrapper(DPCodeStringWrapper, _DPCodeEventWrapper):
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
def read_device_status(
self, device: CustomerDevice
) -> tuple[str, dict[str, Any]] | None:
"""Return the event attributes for the alarm message."""
if (raw_value := device.status.get(self.dpcode)) is None:
if (raw_value := super().read_device_status(device)) is None:
return None
return {"message": b64decode(raw_value).decode("utf-8")}
return ("triggered", {"message": b64decode(raw_value).decode("utf-8")})
class _DoorbellPicWrapper(DPCodeRawWrapper, _DPCodeEventWrapper):
class _DoorbellPicWrapper(DPCodeRawWrapper):
"""Wrapper for a RAW message on DPCode.DOORBELL_PIC.
It is expected that the RAW data is base64/utf8 encoded URL of the picture.
"""
def get_event_attributes(self, device: CustomerDevice) -> dict[str, Any] | None:
def __init__(self, dpcode: str, type_information: Any) -> None:
"""Init _DoorbellPicWrapper."""
super().__init__(dpcode, type_information)
self.options = ["triggered"]
def read_device_status(
self, device: CustomerDevice
) -> tuple[str, dict[str, Any]] | None:
"""Return the event attributes for the doorbell picture."""
if (status := super().read_device_status(device)) is None:
return None
return {"message": status.decode("utf-8")}
return ("triggered", {"message": status.decode("utf-8")})
@dataclass(frozen=True)
class TuyaEventEntityDescription(EventEntityDescription):
"""Describe a Tuya Event entity."""
wrapper_class: type[_DPCodeEventWrapper] = _EventEnumWrapper
wrapper_class: type[DPCodeTypeInformationWrapper] = _EventEnumWrapper
# All descriptions can be found here. Mostly the Enum data types in the
@@ -222,7 +206,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
device: CustomerDevice,
device_manager: Manager,
description: EventEntityDescription,
dpcode_wrapper: _DPCodeEventWrapper,
dpcode_wrapper: DeviceWrapper[tuple[str, dict[str, Any] | None]],
) -> None:
"""Init Tuya event entity."""
super().__init__(device, device_manager)
@@ -236,15 +220,11 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
updated_status_properties: list[str] | None,
dp_timestamps: dict | None = None,
) -> None:
if (
event_type := self._dpcode_wrapper.get_event_type(
self.device, updated_status_properties
)
) is None:
if self._dpcode_wrapper.skip_update(
self.device, updated_status_properties
) or not (event_data := self._dpcode_wrapper.read_device_status(self.device)):
return
self._trigger_event(
event_type,
self._dpcode_wrapper.get_event_attributes(self.device),
)
event_type, event_attributes = event_data
self._trigger_event(event_type, event_attributes)
self.async_write_ha_state()

View File

@@ -198,7 +198,9 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
if speed_wrapper:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
if speed_wrapper.options is not None:
# if speed is from an enum, set speed count from options
# else keep entity default 100
if hasattr(speed_wrapper, "options"):
self._attr_speed_count = len(speed_wrapper.options)
if oscillate_wrapper:

View File

@@ -706,7 +706,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
elif (
color_supported(color_modes)
and color_mode_wrapper is not None
and color_mode_wrapper.options
and WorkMode.WHITE in color_mode_wrapper.options
):
color_modes.add(ColorMode.WHITE)

View File

@@ -22,13 +22,23 @@ class DeviceWrapper[T]:
"""Base device wrapper."""
native_unit: str | None = None
options: list[str] | None = None
suggested_unit: str | None = None
max_value: float
min_value: float
value_step: float
options: list[str]
def skip_update(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> bool:
"""Determine if the wrapper should skip an update.
The default is to always skip, unless overridden in subclasses.
"""
return True
def read_device_status(self, device: CustomerDevice) -> T | None:
"""Read device status and convert to a Home Assistant value."""
raise NotImplementedError
@@ -51,6 +61,19 @@ class DPCodeWrapper(DeviceWrapper):
"""Init DPCodeWrapper."""
self.dpcode = dpcode
def skip_update(
self, device: CustomerDevice, updated_status_properties: list[str] | None
) -> bool:
"""Determine if the wrapper should skip an update.
By default, skip if updated_status_properties is given and
does not include this dpcode.
"""
return (
updated_status_properties is None
or self.dpcode not in updated_status_properties
)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value.
@@ -138,7 +161,6 @@ class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeInformation]):
"""Simple wrapper for EnumTypeInformation values."""
_DPTYPE = EnumTypeInformation
options: list[str]
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
"""Init DPCodeEnumWrapper."""

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.select import SelectEntity, SelectEntityDescription
@@ -402,8 +400,6 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
if TYPE_CHECKING:
assert dpcode_wrapper.options
self._attr_options = dpcode_wrapper.options
@property

View File

@@ -212,7 +212,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
self._attr_fan_speed_list = []
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
if action_wrapper and action_wrapper.options:
if action_wrapper:
if "pause" in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.PAUSE
if "return_to_base" in action_wrapper.options:
@@ -227,7 +227,7 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
if activity_wrapper:
self._attr_supported_features |= VacuumEntityFeature.STATE
if fan_speed_wrapper and fan_speed_wrapper.options:
if fan_speed_wrapper:
self._attr_fan_speed_list = fan_speed_wrapper.options
self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED

View File

@@ -41,7 +41,7 @@
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==7.33.3", "unifi-discovery==1.2.0"],
"requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -4,6 +4,7 @@ import logging
from pyvesync.base_devices import VeSyncHumidifier
from pyvesync.base_devices.fan_base import VeSyncFanBase
from pyvesync.base_devices.fryer_base import VeSyncFryer
from pyvesync.base_devices.outlet_base import VeSyncOutlet
from pyvesync.base_devices.purifier_base import VeSyncPurifier
from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice
@@ -62,3 +63,9 @@ def is_purifier(device: VeSyncBaseDevice) -> bool:
"""Check if the device represents an air purifier."""
return isinstance(device, VeSyncPurifier)
def is_air_fryer(device: VeSyncBaseDevice) -> bool:
"""Check if the device represents an air fryer."""
return isinstance(device, VeSyncFryer)

View File

@@ -62,3 +62,14 @@ OUTLET_NIGHT_LIGHT_LEVEL_ON = "on"
PURIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim"
PURIFIER_NIGHT_LIGHT_LEVEL_OFF = "off"
PURIFIER_NIGHT_LIGHT_LEVEL_ON = "on"
AIR_FRYER_MODE_MAP = {
"cookend": "cooking_end",
"cooking": "cooking",
"cookstop": "cooking_stop",
"heating": "heating",
"preheatend": "preheat_end",
"preheatstop": "preheat_stop",
"pullout": "pull_out",
"standby": "standby",
}

View File

@@ -23,14 +23,15 @@ from homeassistant.const import (
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .common import is_humidifier, is_outlet, rgetattr
from .const import VS_DEVICES, VS_DISCOVERY
from .common import is_air_fryer, is_humidifier, is_outlet, rgetattr
from .const import AIR_FRYER_MODE_MAP, VS_DEVICES, VS_DISCOVERY
from .coordinator import VesyncConfigEntry, VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -47,6 +48,8 @@ class VeSyncSensorEntityDescription(SensorEntityDescription):
exists_fn: Callable[[VeSyncBaseDevice], bool]
use_device_temperature_unit: bool = False
SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
VeSyncSensorEntityDescription(
@@ -167,6 +170,59 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
exists_fn=lambda device: is_humidifier(device)
and device.state.temperature is not None,
),
VeSyncSensorEntityDescription(
key="cook_status",
translation_key="cook_status",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda device: AIR_FRYER_MODE_MAP.get(
device.state.cook_status.lower(), device.state.cook_status.lower()
),
exists_fn=is_air_fryer,
options=[
"cooking_end",
"cooking",
"cooking_stop",
"heating",
"preheat_end",
"preheat_stop",
"pull_out",
"standby",
],
),
VeSyncSensorEntityDescription(
key="current_temp",
translation_key="current_temp",
device_class=SensorDeviceClass.TEMPERATURE,
use_device_temperature_unit=True,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda device: device.state.current_temp,
exists_fn=is_air_fryer,
),
VeSyncSensorEntityDescription(
key="cook_set_temp",
translation_key="cook_set_temp",
device_class=SensorDeviceClass.TEMPERATURE,
use_device_temperature_unit=True,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda device: device.state.cook_set_temp,
exists_fn=is_air_fryer,
),
VeSyncSensorEntityDescription(
key="cook_set_time",
translation_key="cook_set_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
value_fn=lambda device: device.state.cook_set_time,
exists_fn=is_air_fryer,
),
VeSyncSensorEntityDescription(
key="preheat_set_time",
translation_key="preheat_set_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
value_fn=lambda device: device.state.preheat_set_time,
exists_fn=is_air_fryer,
),
)
@@ -232,3 +288,13 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity):
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.device)
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit the value was reported in by the sensor."""
if self.entity_description.use_device_temperature_unit:
if self.device.temp_unit == "celsius":
return UnitOfTemperature.CELSIUS
if self.device.temp_unit == "fahrenheit":
return UnitOfTemperature.FAHRENHEIT
return super().native_unit_of_measurement

View File

@@ -92,9 +92,31 @@
"air_quality": {
"name": "Air quality"
},
"cook_set_temp": {
"name": "Cooking set temperature"
},
"cook_set_time": {
"name": "Cooking set time"
},
"cook_status": {
"name": "Cooking status",
"state": {
"cooking": "Cooking",
"cooking_end": "Cooking finished",
"cooking_stop": "Cooking stopped",
"heating": "Preheating",
"preheat_end": "Preheating finished",
"preheat_stop": "Preheating stopped",
"pull_out": "Drawer pulled out",
"standby": "[%key:common::state::standby%]"
}
},
"current_power": {
"name": "Current power"
},
"current_temp": {
"name": "Current temperature"
},
"current_voltage": {
"name": "Current voltage"
},
@@ -112,6 +134,9 @@
},
"filter_life": {
"name": "Filter lifetime"
},
"preheat_set_time": {
"name": "Preheating set time"
}
},
"switch": {

View File

@@ -1,8 +1,9 @@
{
"domain": "waterfurnace",
"name": "WaterFurnace",
"codeowners": [],
"codeowners": ["@sdague", "@masterkoppa"],
"documentation": "https://www.home-assistant.io/integrations/waterfurnace",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["waterfurnace"],
"quality_scale": "legacy",

View File

@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==0.0.82", "serialx==0.5.0"],
"requirements": ["zha==0.0.83", "serialx==0.5.0"],
"usb": [
{
"description": "*2652*",

View File

@@ -7468,7 +7468,7 @@
},
"waterfurnace": {
"name": "WaterFurnace",
"integration_type": "hub",
"integration_type": "device",
"config_flow": false,
"iot_class": "cloud_polling"
},

View File

@@ -537,7 +537,7 @@ def _validate_range[_T: dict[str, Any]](
_NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema(
{
vol.Required("chosen_selector"): vol.In(["number", "entity"]),
vol.Required("active_choice"): vol.In(["number", "entity"]),
vol.Optional("entity"): cv.entity_id,
vol.Optional("number"): vol.Coerce(float),
}
@@ -548,7 +548,7 @@ def _validate_number_or_entity(value: dict | float | str) -> float | str:
"""Validate number or entity selector result."""
if isinstance(value, dict):
_NUMBER_OR_ENTITY_CHOOSE_SCHEMA(value)
return value[value["chosen_selector"]] # type: ignore[no-any-return]
return value[value["active_choice"]] # type: ignore[no-any-return]
return value

View File

@@ -39,8 +39,8 @@ habluetooth==5.8.0
hass-nabucasa==1.7.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251229.0
home-assistant-intents==2026.1.1
home-assistant-frontend==20251229.1
home-assistant-intents==2026.1.6
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
@@ -226,3 +226,6 @@ gql<4.0.0
# Pin pytest-rerunfailures to prevent accidental breaks
pytest-rerunfailures==16.0.1
# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475
aiomqtt>=2.5.0

View File

@@ -16,7 +16,7 @@ from homeassistant.const import Platform
if TYPE_CHECKING:
# InferenceResult is available only from astroid >= 2.12.0
# pre-commit should still work on out of date environments
# prek should still work on out of date environments
from astroid.typing import InferenceResult
_COMMON_ARGUMENTS: dict[str, list[str]] = {

2
requirements.txt generated
View File

@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
hass-nabucasa==1.7.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.1.1
home-assistant-intents==2026.1.6
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6

28
requirements_all.txt generated
View File

@@ -1145,7 +1145,7 @@ growattServer==1.7.1
gspread==5.5.0
# homeassistant.components.profiler
guppy3==3.1.5;python_version<'3.14'
guppy3==3.1.6
# homeassistant.components.iaqualink
h2==4.3.0
@@ -1213,10 +1213,10 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251229.0
home-assistant-frontend==20251229.1
# homeassistant.components.conversation
home-assistant-intents==2026.1.1
home-assistant-intents==2026.1.6
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -1662,9 +1662,6 @@ openai==2.11.0
# homeassistant.components.openerz
openerz-api==0.3.0
# homeassistant.components.openevse
openevsewifi==1.1.2
# homeassistant.components.openhome
openhomedevice==2.2.0
@@ -1867,7 +1864,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.34.0
pyTibber==0.34.1
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1931,7 +1928,7 @@ pyblu==2.0.5
pybotvac==0.0.28
# homeassistant.components.braviatv
pybravia==0.3.4
pybravia==0.4.1
# homeassistant.components.nissan_leaf
pycarwings2==2.14
@@ -2133,7 +2130,7 @@ pyitachip2ir==0.0.7
pyituran==0.1.5
# homeassistant.components.jvc_projector
pyjvcprojector==1.1.2
pyjvcprojector==1.1.3
# homeassistant.components.kaleidescape
pykaleidescape==1.0.2
@@ -2558,6 +2555,9 @@ python-open-router==0.3.3
# homeassistant.components.swiss_public_transport
python-opendata-transport==0.5.0
# homeassistant.components.openevse
python-openevse-http==0.2.1
# homeassistant.components.opensky
python-opensky==1.0.1
@@ -2581,7 +2581,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==4.2.0
python-roborock==4.2.1
# homeassistant.components.smarttub
python-smarttub==0.0.46
@@ -2838,7 +2838,7 @@ sensoterra==2.0.1
sentence-stream==1.2.0
# homeassistant.components.sentry
sentry-sdk==1.45.1
sentry-sdk==2.48.0
# homeassistant.components.homeassistant_hardware
# homeassistant.components.zha
@@ -2896,7 +2896,7 @@ solaredge-local==0.2.3
solaredge-web==0.0.1
# homeassistant.components.solarlog
solarlog_cli==0.6.1
solarlog_cli==0.7.0
# homeassistant.components.solax
solax==3.2.3
@@ -3078,7 +3078,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==7.33.3
uiprotect==8.0.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -3277,7 +3277,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.82
zha==0.0.83
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@@ -15,7 +15,7 @@ librt==0.2.1
license-expression==30.4.3
mock-open==1.4.0
mypy-dev==1.19.0a4
pre-commit==4.2.0
prek==0.2.26
pydantic==2.12.2
pylint==4.0.1
pylint-per-file-ignores==1.4.0

View File

@@ -1015,7 +1015,7 @@ growattServer==1.7.1
gspread==5.5.0
# homeassistant.components.profiler
guppy3==3.1.5;python_version<'3.14'
guppy3==3.1.6
# homeassistant.components.iaqualink
h2==4.3.0
@@ -1071,10 +1071,10 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20251229.0
home-assistant-frontend==20251229.1
# homeassistant.components.conversation
home-assistant-intents==2026.1.1
home-assistant-intents==2026.1.6
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -1445,9 +1445,6 @@ openai==2.11.0
# homeassistant.components.openerz
openerz-api==0.3.0
# homeassistant.components.openevse
openevsewifi==1.1.2
# homeassistant.components.openhome
openhomedevice==2.2.0
@@ -1598,7 +1595,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.34.0
pyTibber==0.34.1
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1653,7 +1650,7 @@ pyblu==2.0.5
pybotvac==0.0.28
# homeassistant.components.braviatv
pybravia==0.3.4
pybravia==0.4.1
# homeassistant.components.cloudflare
pycfdns==3.0.0
@@ -1804,7 +1801,7 @@ pyisy==3.4.1
pyituran==0.1.5
# homeassistant.components.jvc_projector
pyjvcprojector==1.1.2
pyjvcprojector==1.1.3
# homeassistant.components.kaleidescape
pykaleidescape==1.0.2
@@ -2148,6 +2145,9 @@ python-open-router==0.3.3
# homeassistant.components.swiss_public_transport
python-opendata-transport==0.5.0
# homeassistant.components.openevse
python-openevse-http==0.2.1
# homeassistant.components.opensky
python-opensky==1.0.1
@@ -2168,7 +2168,7 @@ python-pooldose==0.8.1
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==4.2.0
python-roborock==4.2.1
# homeassistant.components.smarttub
python-smarttub==0.0.46
@@ -2380,7 +2380,7 @@ sensoterra==2.0.1
sentence-stream==1.2.0
# homeassistant.components.sentry
sentry-sdk==1.45.1
sentry-sdk==2.48.0
# homeassistant.components.homeassistant_hardware
# homeassistant.components.zha
@@ -2423,7 +2423,7 @@ soco==0.30.14
solaredge-web==0.0.1
# homeassistant.components.solarlog
solarlog_cli==0.6.1
solarlog_cli==0.7.0
# homeassistant.components.solax
solax==3.2.3
@@ -2572,7 +2572,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
uiprotect==7.33.3
uiprotect==8.0.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2741,7 +2741,7 @@ zeroconf==0.148.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.82
zha==0.0.83
# homeassistant.components.zwave_js
zwave-js-server-python==0.67.1

View File

@@ -217,6 +217,9 @@ gql<4.0.0
# Pin pytest-rerunfailures to prevent accidental breaks
pytest-rerunfailures==16.0.1
# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475
aiomqtt>=2.5.0
"""
GENERATED_MESSAGE = (

View File

@@ -427,7 +427,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
if config.action == "generate" and manifests_resorted:
subprocess.run(
[
"pre-commit",
"prek",
"run",
"--hook-stage",
"manual",

View File

@@ -15,7 +15,7 @@ printf "%s\n" $files
echo "=============="
echo "LINT with ruff"
echo "=============="
pre-commit run ruff-check --files $files
prek run ruff-check --files $files
echo "================"
echo "LINT with pylint"
echo "================"

View File

@@ -119,7 +119,7 @@ async def pylint(files):
async def ruff(files):
"""Exec ruff."""
_, log = await async_exec("pre-commit", "run", "ruff", "--files", *files)
_, log = await async_exec("prek", "run", "ruff", "--files", *files)
res = []
for line in log.splitlines():
line = line.split(":")

View File

@@ -31,7 +31,7 @@ fi
script/bootstrap
pre-commit install
prek install
hass --script ensure_config -c config

View File

@@ -10,6 +10,8 @@ from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
CONF_ABOVE,
CONF_BELOW,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
@@ -25,6 +27,12 @@ from homeassistant.helpers import (
floor_registry as fr,
label_registry as lr,
)
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
CONF_UPPER_LIMIT,
ThresholdType,
)
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, mock_device_registry
@@ -343,6 +351,123 @@ def parametrize_trigger_states(
return tests
def parametrize_numerical_attribute_changed_trigger_states(
trigger: str, state: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for numerical changed triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 50}),
(state, {attribute: 100}),
],
other_states=[(state, {attribute: None})],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
target_states=[
(state, {attribute: 50}),
(state, {attribute: 100}),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 0}),
],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 50}),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 100}),
],
retrigger_on_target_state=True,
),
]
def parametrize_numerical_attribute_crossed_threshold_trigger_states(
trigger: str, state: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for numerical crossed threshold triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(state, {attribute: 50}),
(state, {attribute: 60}),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 0}),
(state, {attribute: 100}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 100}),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 50}),
(state, {attribute: 60}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
},
target_states=[
(state, {attribute: 50}),
(state, {attribute: 100}),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 0}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 50}),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 100}),
],
),
]
async def arm_trigger(
hass: HomeAssistant,
trigger: str,

View File

@@ -13,6 +13,7 @@ import pytest
from homeassistant.components.braviatv.const import (
CONF_NICKNAME,
CONF_USE_PSK,
CONF_USE_SSL,
DOMAIN,
NICKNAME_PREFIX,
)
@@ -131,7 +132,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None:
assert result["step_id"] == "authorize"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_USE_PSK: False}
result["flow_id"], user_input={CONF_USE_PSK: False, CONF_USE_SSL: False}
)
assert result["type"] is FlowResultType.FORM
@@ -148,6 +149,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None:
CONF_HOST: "bravia-host",
CONF_PIN: "1234",
CONF_USE_PSK: False,
CONF_USE_SSL: False,
CONF_MAC: "AA:BB:CC:DD:EE:FF",
CONF_CLIENT_ID: uuid,
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
@@ -307,8 +309,17 @@ async def test_duplicate_error(hass: HomeAssistant) -> None:
assert result["reason"] == "already_configured"
async def test_create_entry(hass: HomeAssistant) -> None:
"""Test that entry is added correctly with PIN auth."""
@pytest.mark.parametrize(
("use_psk", "use_ssl"),
[
(True, False),
(False, False),
(True, True),
(False, True),
],
)
async def test_create_entry(hass: HomeAssistant, use_psk, use_ssl) -> None:
"""Test that entry is added correctly."""
uuid = await instance_id.async_get(hass)
with (
@@ -328,14 +339,14 @@ async def test_create_entry(hass: HomeAssistant) -> None:
assert result["step_id"] == "authorize"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_USE_PSK: False}
result["flow_id"], user_input={CONF_USE_PSK: use_psk, CONF_USE_SSL: use_ssl}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "pin"
assert result["step_id"] == "psk" if use_psk else "pin"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PIN: "1234"}
result["flow_id"], user_input={CONF_PIN: "secret"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -343,50 +354,18 @@ async def test_create_entry(hass: HomeAssistant) -> None:
assert result["title"] == "BRAVIA TV-Model"
assert result["data"] == {
CONF_HOST: "bravia-host",
CONF_PIN: "1234",
CONF_USE_PSK: False,
CONF_MAC: "AA:BB:CC:DD:EE:FF",
CONF_CLIENT_ID: uuid,
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
}
async def test_create_entry_psk(hass: HomeAssistant) -> None:
"""Test that entry is added correctly with PSK auth."""
with (
patch("pybravia.BraviaClient.connect"),
patch("pybravia.BraviaClient.set_wol_mode"),
patch(
"pybravia.BraviaClient.get_system_info",
return_value=BRAVIA_SYSTEM_INFO,
),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_USE_PSK: True}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "psk"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PIN: "mypsk"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "very_unique_string"
assert result["title"] == "BRAVIA TV-Model"
assert result["data"] == {
CONF_HOST: "bravia-host",
CONF_PIN: "mypsk",
CONF_USE_PSK: True,
CONF_PIN: "secret",
CONF_USE_PSK: use_psk,
CONF_USE_SSL: use_ssl,
CONF_MAC: "AA:BB:CC:DD:EE:FF",
**(
{
CONF_CLIENT_ID: uuid,
CONF_NICKNAME: f"{NICKNAME_PREFIX} {uuid[:6]}",
}
if not use_psk
else {}
),
}

View File

@@ -20,25 +20,19 @@ from homeassistant.components.climate.trigger import CONF_HVAC_MODE
from homeassistant.const import (
ATTR_LABEL_ID,
ATTR_TEMPERATURE,
CONF_ABOVE,
CONF_BELOW,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
CONF_UPPER_LIMIT,
ThresholdType,
async_validate_trigger_config,
)
from homeassistant.helpers.trigger import async_validate_trigger_config
from tests.components import (
StateDescription,
arm_trigger,
other_states,
parametrize_numerical_attribute_changed_trigger_states,
parametrize_numerical_attribute_crossed_threshold_trigger_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
@@ -153,123 +147,6 @@ async def test_climate_trigger_validation(
)
def parametrize_xxx_changed_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_changed triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[(HVACMode.AUTO, {attribute: None})],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
target_states=[
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 0}),
],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 50}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 100}),
],
retrigger_on_target_state=True,
),
]
def parametrize_xxx_crossed_threshold_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 60}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 100}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 60}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
},
target_states=[
(HVACMode.AUTO, {attribute: 50}),
(HVACMode.AUTO, {attribute: 100}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 0}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(HVACMode.AUTO, {attribute: 0}),
(HVACMode.AUTO, {attribute: 50}),
],
other_states=[
(HVACMode.AUTO, {attribute: None}),
(HVACMode.AUTO, {attribute: 100}),
],
),
]
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
@@ -351,29 +228,37 @@ async def test_climate_state_trigger_behavior_any(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_changed_trigger_states(
"climate.current_humidity_changed", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_changed_trigger_states(
"climate.current_humidity_changed", HVACMode.AUTO, ATTR_CURRENT_HUMIDITY
),
*parametrize_xxx_changed_trigger_states(
"climate.current_temperature_changed", ATTR_CURRENT_TEMPERATURE
*parametrize_numerical_attribute_changed_trigger_states(
"climate.current_temperature_changed",
HVACMode.AUTO,
ATTR_CURRENT_TEMPERATURE,
),
*parametrize_xxx_changed_trigger_states(
"climate.target_humidity_changed", ATTR_HUMIDITY
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY
),
*parametrize_xxx_changed_trigger_states(
"climate.target_temperature_changed", ATTR_TEMPERATURE
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_temperature_changed", HVACMode.AUTO, ATTR_TEMPERATURE
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_CURRENT_HUMIDITY,
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_CURRENT_TEMPERATURE,
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -512,17 +397,23 @@ async def test_climate_state_trigger_behavior_first(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_CURRENT_HUMIDITY,
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_CURRENT_TEMPERATURE,
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -661,17 +552,23 @@ async def test_climate_state_trigger_behavior_last(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_CURRENT_HUMIDITY,
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_CURRENT_TEMPERATURE,
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",

View File

@@ -11,25 +11,14 @@ from homeassistant.components.humidifier.const import (
ATTR_CURRENT_HUMIDITY,
HumidifierAction,
)
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_ABOVE,
CONF_BELOW,
CONF_ENTITY_ID,
STATE_OFF,
STATE_ON,
)
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
CONF_UPPER_LIMIT,
ThresholdType,
)
from tests.components import (
StateDescription,
arm_trigger,
parametrize_numerical_attribute_changed_trigger_states,
parametrize_numerical_attribute_crossed_threshold_trigger_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
@@ -82,123 +71,6 @@ async def test_humidifier_triggers_gated_by_labs_flag(
) in caplog.text
def parametrize_xxx_changed_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_changed triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={},
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 100}),
],
other_states=[(STATE_ON, {attribute: None})],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
target_states=[
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 100}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 0}),
],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 50}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 100}),
],
retrigger_on_target_state=True,
),
]
def parametrize_xxx_crossed_threshold_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 60}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 100}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 100}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 60}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
},
target_states=[
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 100}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 0}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 50}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 100}),
],
),
]
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
@@ -265,11 +137,13 @@ async def test_humidifier_state_trigger_behavior_any(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_changed_trigger_states(
"humidifier.current_humidity_changed", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_changed_trigger_states(
"humidifier.current_humidity_changed", STATE_ON, ATTR_CURRENT_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidifier.current_humidity_crossed_threshold",
STATE_ON,
ATTR_CURRENT_HUMIDITY,
),
*parametrize_trigger_states(
trigger="humidifier.started_drying",
@@ -386,8 +260,10 @@ async def test_humidifier_state_trigger_behavior_first(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidifier.current_humidity_crossed_threshold",
STATE_ON,
ATTR_CURRENT_HUMIDITY,
),
*parametrize_trigger_states(
trigger="humidifier.started_drying",
@@ -504,8 +380,10 @@ async def test_humidifier_state_trigger_behavior_last(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"humidifier.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"humidifier.current_humidity_crossed_threshold",
STATE_ON,
ATTR_CURRENT_HUMIDITY,
),
*parametrize_trigger_states(
trigger="humidifier.started_drying",

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -66,3 +66,43 @@ async def test_fail_query(
assert len(hass.states.async_entity_ids()) == 6
departure_sensor = hass.states.get("sensor.mock_title_departure")
assert departure_sensor.state == STATE_UNAVAILABLE
async def test_no_departures(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_israelrail: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test handling when there are no departures available."""
await init_integration(hass, mock_config_entry)
assert len(hass.states.async_entity_ids()) == 6
# Simulate no departures (e.g., after-hours)
mock_israelrail.query.return_value = []
await goto_future(hass, freezer)
# All sensors should still exist
assert len(hass.states.async_entity_ids()) == 6
# Departure sensors should have unknown state (None)
departure_sensor = hass.states.get("sensor.mock_title_departure")
assert departure_sensor.state == STATE_UNKNOWN
departure_sensor_1 = hass.states.get("sensor.mock_title_departure_1")
assert departure_sensor_1.state == STATE_UNKNOWN
departure_sensor_2 = hass.states.get("sensor.mock_title_departure_2")
assert departure_sensor_2.state == STATE_UNKNOWN
# Non-departure sensors (platform, trains, train_number) also access index 0
# and should have unknown state when no departures available
platform_sensor = hass.states.get("sensor.mock_title_platform")
assert platform_sensor.state == STATE_UNKNOWN
trains_sensor = hass.states.get("sensor.mock_title_trains")
assert trains_sensor.state == STATE_UNKNOWN
train_number_sensor = hass.states.get("sensor.mock_title_train_number")
assert train_number_sensor.state == STATE_UNKNOWN

View File

@@ -7,25 +7,14 @@ from unittest.mock import patch
import pytest
from homeassistant.components.light import ATTR_BRIGHTNESS
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_ABOVE,
CONF_BELOW,
CONF_ENTITY_ID,
STATE_OFF,
STATE_ON,
)
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
CONF_UPPER_LIMIT,
ThresholdType,
)
from tests.components import (
StateDescription,
arm_trigger,
parametrize_numerical_attribute_changed_trigger_states,
parametrize_numerical_attribute_crossed_threshold_trigger_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
@@ -76,122 +65,6 @@ async def test_light_triggers_gated_by_labs_flag(
) in caplog.text
def parametrize_xxx_changed_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_changed triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 100}),
],
other_states=[(STATE_ON, {attribute: None})],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
target_states=[
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 100}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 0}),
],
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 50}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 100}),
],
retrigger_on_target_state=True,
),
]
def parametrize_xxx_crossed_threshold_trigger_states(
trigger: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 60}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 100}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 100}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 60}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
},
target_states=[
(STATE_ON, {attribute: 50}),
(STATE_ON, {attribute: 100}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 0}),
],
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
},
target_states=[
(STATE_ON, {attribute: 0}),
(STATE_ON, {attribute: 50}),
],
other_states=[
(STATE_ON, {attribute: None}),
(STATE_ON, {attribute: 100}),
],
),
]
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
@@ -258,11 +131,11 @@ async def test_light_state_trigger_behavior_any(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_changed_trigger_states(
"light.brightness_changed", ATTR_BRIGHTNESS
*parametrize_numerical_attribute_changed_trigger_states(
"light.brightness_changed", STATE_ON, ATTR_BRIGHTNESS
),
*parametrize_xxx_crossed_threshold_trigger_states(
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
),
],
)
@@ -369,8 +242,8 @@ async def test_light_state_trigger_behavior_first(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
),
],
)
@@ -477,8 +350,8 @@ async def test_light_state_trigger_behavior_last(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"light.brightness_crossed_threshold", ATTR_BRIGHTNESS
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"light.brightness_crossed_threshold", STATE_ON, ATTR_BRIGHTNESS
),
],
)

View File

@@ -16,22 +16,23 @@ def mock_charger() -> Generator[MagicMock]:
"""Create a mock OpenEVSE charger."""
with (
patch(
"homeassistant.components.openevse.openevsewifi.Charger",
"homeassistant.components.openevse.OpenEVSE",
autospec=True,
) as mock,
patch(
"homeassistant.components.openevse.config_flow.openevsewifi.Charger",
"homeassistant.components.openevse.config_flow.OpenEVSE",
new=mock,
),
):
charger = mock.return_value
charger.getStatus.return_value = "Charging"
charger.getChargeTimeElapsed.return_value = 3600 # 60 minutes in seconds
charger.getAmbientTemperature.return_value = 25.5
charger.getIRTemperature.return_value = 30.2
charger.getRTCTemperature.return_value = 28.7
charger.getUsageSession.return_value = 15000 # 15 kWh in Wh
charger.getUsageTotal.return_value = 500000 # 500 kWh in Wh
charger.update = AsyncMock()
charger.status = "Charging"
charger.charge_time_elapsed = 3600 # 60 minutes in seconds
charger.ambient_temperature = 25.5
charger.ir_temperature = 30.2
charger.rtc_temperature = 28.7
charger.usage_session = 15000 # 15 kWh in Wh
charger.usage_total = 500000 # 500 kWh in Wh
charger.charging_current = 32.0
yield charger

View File

@@ -45,7 +45,7 @@ async def test_user_flow_flaky(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
mock_charger.getStatus.side_effect = AttributeError
mock_charger.test_and_get.side_effect = TimeoutError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
@@ -54,7 +54,7 @@ async def test_user_flow_flaky(
assert result["step_id"] == "user"
assert result["errors"] == {"host": "cannot_connect"}
mock_charger.getStatus.side_effect = "Charging"
mock_charger.test_and_get.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
@@ -112,7 +112,7 @@ async def test_import_flow_bad(
mock_setup_entry: AsyncMock,
) -> None:
"""Test import flow with bad charger."""
mock_charger.getStatus.side_effect = AttributeError
mock_charger.test_and_get.side_effect = TimeoutError
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: "10.0.0.131"}

View File

@@ -6,7 +6,6 @@ import logging
import os
from pathlib import Path
import socket
import sys
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
@@ -73,9 +72,6 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None:
await hass.async_block_till_done()
@pytest.mark.skipif(
sys.version_info >= (3, 14), reason="not yet available on Python 3.14"
)
async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test we can setup and the service is registered."""
test_dir = tmp_path / "profiles"
@@ -107,24 +103,6 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None:
await hass.async_block_till_done()
@pytest.mark.skipif(sys.version_info < (3, 14), reason="still works on python 3.13")
async def test_memory_usage_py313(hass: HomeAssistant, tmp_path: Path) -> None:
"""Test raise an error on python3.13."""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert hass.services.has_service(DOMAIN, SERVICE_MEMORY)
with pytest.raises(
HomeAssistantError,
match="Memory profiling is not supported on Python 3.14. Please use Python 3.13.",
):
await hass.services.async_call(
DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True
)
async def test_object_growth_logging(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,

View File

@@ -325,6 +325,38 @@ async def test_camera_image(
assert image.content == SMALLEST_VALID_JPEG_BYTES
async def test_camera_live_view_no_subscription(
hass: HomeAssistant,
mock_ring_client,
mock_ring_devices,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test live view camera skips recording URL when no subscription."""
await setup_platform(hass, Platform.CAMERA)
front_camera_mock = mock_ring_devices.get_device(765432)
# Set device to not have subscription
front_camera_mock.has_subscription = False
state = hass.states.get("camera.front_live_view")
assert state is not None
# Reset mock call counts
front_camera_mock.async_recording_url.reset_mock()
# Trigger coordinator update
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# For cameras without subscription, recording URL should NOT be fetched
front_camera_mock.async_recording_url.assert_not_called()
# Requesting an image without subscription should raise an error
with pytest.raises(HomeAssistantError):
await async_get_image(hass, "camera.front_live_view")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_camera_stream_attributes(
hass: HomeAssistant,

View File

@@ -751,62 +751,6 @@
'state': 'sweep_moping',
})
# ---
# name: test_sensors[sensor.roborock_q7_total_cleaning_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.roborock_q7_total_cleaning_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Total cleaning time',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'total_cleaning_time',
'unique_id': 'total_cleaning_time_q7_duid',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
})
# ---
# name: test_sensors[sensor.roborock_q7_total_cleaning_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Roborock Q7 Total cleaning time',
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
'context': <ANY>,
'entity_id': 'sensor.roborock_q7_total_cleaning_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '50.0',
})
# ---
# name: test_sensors[sensor.roborock_s7_2_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -1,12 +1,14 @@
"""The tests for the telegram.notify platform."""
from unittest.mock import patch
from typing import Any
from unittest.mock import AsyncMock, call, patch
from homeassistant import config as hass_config
from homeassistant.components import notify
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE
from homeassistant.components.telegram import DOMAIN
from homeassistant.const import SERVICE_RELOAD
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, ServiceRegistry
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
@@ -54,3 +56,108 @@ async def test_reload_notify(
issue_id="migrate_notify",
)
assert len(issue_registry.issues) == 1
async def test_notify(hass: HomeAssistant) -> None:
"""Test notify."""
assert await async_setup_component(
hass,
notify.DOMAIN,
{
notify.DOMAIN: [
{
"name": DOMAIN,
"platform": DOMAIN,
"chat_id": 1,
},
]
},
)
await hass.async_block_till_done()
original_call = ServiceRegistry.async_call
with patch(
"homeassistant.core.ServiceRegistry.async_call", new_callable=AsyncMock
) as mock_service_call:
# setup mock
async def call_service(*args, **kwargs) -> Any:
if args[0] == notify.DOMAIN:
return await original_call(
hass.services, args[0], args[1], args[2], kwargs["blocking"]
)
return AsyncMock()
mock_service_call.side_effect = call_service
# test send message
data: dict[str, Any] = {"title": "mock title", "message": "mock message"}
await hass.services.async_call(
notify.DOMAIN,
DOMAIN,
{ATTR_TITLE: "mock title", ATTR_MESSAGE: "mock message"},
blocking=True,
)
await hass.async_block_till_done()
assert mock_service_call.mock_calls == [
call(
"notify",
"telegram",
data,
blocking=True,
),
call(
"telegram_bot",
"send_message",
{"target": 1, "title": "mock title", "message": "mock message"},
False,
None,
None,
False,
),
]
mock_service_call.reset_mock()
# test send file
data = {
ATTR_TITLE: "mock title",
ATTR_MESSAGE: "mock message",
ATTR_DATA: {
"photo": {"url": "https://mock/photo.jpg", "caption": "mock caption"}
},
}
await hass.services.async_call(
notify.DOMAIN,
DOMAIN,
data,
blocking=True,
)
await hass.async_block_till_done()
assert mock_service_call.mock_calls == [
call(
"notify",
"telegram",
data,
blocking=True,
),
call(
"telegram_bot",
"send_photo",
{
"target": 1,
"url": "https://mock/photo.jpg",
"caption": "mock caption",
},
False,
None,
None,
False,
),
]

View File

@@ -77,6 +77,20 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = {
"Humidifier 6000s": [
("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-6000s-detail.json")
],
"CS158-AF Air Fryer Standby": [
(
"post",
"/cloud/v1/deviceManaged/bypass",
"air-fryer-CS158-AF-detail-standby.json",
)
],
"CS158-AF Air Fryer Cooking": [
(
"post",
"/cloud/v1/deviceManaged/bypass",
"air-fryer-CS158-AF-detail-cooking.json",
)
],
}

View File

@@ -0,0 +1,19 @@
{
"traceId": "1234",
"code": 0,
"msg": "request success",
"module": null,
"stacktrace": null,
"result": {
"returnStatus": {
"curentTemp": 17,
"cookSetTemp": 180,
"mode": "manual",
"cookSetTime": 15,
"cookLastTime": 10,
"cookStatus": "cooking",
"tempUnit": "celsius",
"accountId": ""
}
}
}

View File

@@ -0,0 +1,12 @@
{
"traceId": "1234",
"code": 0,
"msg": "request success",
"module": null,
"stacktrace": null,
"result": {
"returnStatus": {
"cookStatus": "standby"
}
}
}

View File

@@ -231,6 +231,56 @@
"wifiMac": "00:10:f0:aa:bb:cc",
"mistLevel": 2
}
},
{
"deviceRegion": "EU",
"isOwner": true,
"authKey": null,
"deviceName": "CS158-AF Air Fryer Standby",
"deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/wifi_airfryer_cs158-af_eu_240.png",
"cid": "CS158Standby",
"deviceStatus": "off",
"connectionStatus": "online",
"connectionType": "wifi",
"deviceType": "CS158-AF",
"type": "SKA",
"uuid": "##_REDACTED_##",
"configModule": "WiFi_AirFryer_CS158-AF_EU",
"macID": null,
"mode": null,
"speed": null,
"currentFirmVersion": null,
"subDeviceNo": null,
"subDeviceType": null,
"deviceFirstSetupTime": "Dec 3, 2025 1:52:19 PM",
"subDeviceList": null,
"extension": null,
"deviceProp": null
},
{
"deviceRegion": "EU",
"isOwner": true,
"authKey": null,
"deviceName": "CS158-AF Air Fryer Cooking",
"deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/wifi_airfryer_cs158-af_eu_240.png",
"cid": "CS158Cooking",
"deviceStatus": "off",
"connectionStatus": "online",
"connectionType": "wifi",
"deviceType": "CS158-AF",
"type": "SKA",
"uuid": "##_REDACTED_##",
"configModule": "WiFi_AirFryer_CS158-AF_EU",
"macID": null,
"mode": null,
"speed": null,
"currentFirmVersion": null,
"subDeviceNo": null,
"subDeviceType": null,
"deviceFirstSetupTime": "Dec 3, 2025 1:52:19 PM",
"subDeviceList": null,
"extension": null,
"deviceProp": null
}
]
}

View File

@@ -147,6 +147,80 @@
list([
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Cooking',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Cooking',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][entities]
list([
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Standby',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Standby',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][entities]
list([
])
# ---
# name: test_sensor_state[Dimmable Light][devices]
list([
DeviceRegistryEntrySnapshot({

View File

@@ -399,6 +399,80 @@
'state': 'on',
})
# ---
# name: test_fan_state[CS158-AF Air Fryer Cooking][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Cooking',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Cooking',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_fan_state[CS158-AF Air Fryer Cooking][entities]
list([
])
# ---
# name: test_fan_state[CS158-AF Air Fryer Standby][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Standby',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Standby',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_fan_state[CS158-AF Air Fryer Standby][entities]
list([
])
# ---
# name: test_fan_state[Dimmable Light][devices]
list([
DeviceRegistryEntrySnapshot({

View File

@@ -147,6 +147,80 @@
list([
])
# ---
# name: test_humidifier_state[CS158-AF Air Fryer Cooking][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Cooking',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Cooking',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_humidifier_state[CS158-AF Air Fryer Cooking][entities]
list([
])
# ---
# name: test_humidifier_state[CS158-AF Air Fryer Standby][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Standby',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Standby',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_humidifier_state[CS158-AF Air Fryer Standby][entities]
list([
])
# ---
# name: test_humidifier_state[Dimmable Light][devices]
list([
DeviceRegistryEntrySnapshot({

View File

@@ -147,6 +147,80 @@
list([
])
# ---
# name: test_light_state[CS158-AF Air Fryer Cooking][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Cooking',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Cooking',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_light_state[CS158-AF Air Fryer Cooking][entities]
list([
])
# ---
# name: test_light_state[CS158-AF Air Fryer Standby][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Standby',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Standby',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_light_state[CS158-AF Air Fryer Standby][entities]
list([
])
# ---
# name: test_light_state[Dimmable Light][devices]
list([
DeviceRegistryEntrySnapshot({

View File

@@ -587,6 +587,628 @@
'state': '5',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Cooking',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Cooking',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][entities]
list([
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_current_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Current temperature',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'current_temp',
'unique_id': 'CS158Cooking-current_temp',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_set_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Cooking set temperature',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cook_set_temp',
'unique_id': 'CS158Cooking-cook_set_temp',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_set_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Cooking set time',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cook_set_time',
'unique_id': 'CS158Cooking-cook_set_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_preheating_set_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Preheating set time',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'preheat_set_time',
'unique_id': 'CS158Cooking-preheat_set_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'cooking_end',
'cooking',
'cooking_stop',
'heating',
'preheat_end',
'preheat_stop',
'pull_out',
'standby',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Cooking status',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cook_status',
'unique_id': 'CS158Cooking-cook_status',
'unit_of_measurement': None,
}),
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_cooking_set_temperature]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'CS158-AF Air Fryer Cooking Cooking set temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_set_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '180',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_cooking_set_time]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'CS158-AF Air Fryer Cooking Cooking set time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_set_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '15',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_cooking_status]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'CS158-AF Air Fryer Cooking Cooking status',
'options': list([
'cooking_end',
'cooking',
'cooking_stop',
'heating',
'preheat_end',
'preheat_stop',
'pull_out',
'standby',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_cooking_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'cooking',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_current_temperature]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'CS158-AF Air Fryer Cooking Current temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_current_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '17',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Cooking][sensor.cs158_af_air_fryer_cooking_preheating_set_time]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'CS158-AF Air Fryer Cooking Preheating set time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_cooking_preheating_set_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Standby',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Standby',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][entities]
list([
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_standby_current_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Current temperature',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'current_temp',
'unique_id': 'CS158Standby-current_temp',
'unit_of_measurement': None,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_set_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Cooking set temperature',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cook_set_temp',
'unique_id': 'CS158Standby-cook_set_temp',
'unit_of_measurement': None,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_set_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Cooking set time',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cook_set_time',
'unique_id': 'CS158Standby-cook_set_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_standby_preheating_set_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Preheating set time',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'preheat_set_time',
'unique_id': 'CS158Standby-preheat_set_time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'cooking_end',
'cooking',
'cooking_stop',
'heating',
'preheat_end',
'preheat_stop',
'pull_out',
'standby',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Cooking status',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cook_status',
'unique_id': 'CS158Standby-cook_status',
'unit_of_measurement': None,
}),
])
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_cooking_set_temperature]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'CS158-AF Air Fryer Standby Cooking set temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_set_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_cooking_set_time]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'CS158-AF Air Fryer Standby Cooking set time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_set_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_cooking_status]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'CS158-AF Air Fryer Standby Cooking status',
'options': list([
'cooking_end',
'cooking',
'cooking_stop',
'heating',
'preheat_end',
'preheat_stop',
'pull_out',
'standby',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_standby_cooking_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'standby',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_current_temperature]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'CS158-AF Air Fryer Standby Current temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_standby_current_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_state[CS158-AF Air Fryer Standby][sensor.cs158_af_air_fryer_standby_preheating_set_time]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'CS158-AF Air Fryer Standby Preheating set time',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'sensor.cs158_af_air_fryer_standby_preheating_set_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_state[Dimmable Light][devices]
list([
DeviceRegistryEntrySnapshot({

View File

@@ -469,6 +469,80 @@
'state': 'on',
})
# ---
# name: test_switch_state[CS158-AF Air Fryer Cooking][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Cooking',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Cooking',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_switch_state[CS158-AF Air Fryer Cooking][entities]
list([
])
# ---
# name: test_switch_state[CS158-AF Air Fryer Standby][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Standby',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Standby',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_switch_state[CS158-AF Air Fryer Standby][entities]
list([
])
# ---
# name: test_switch_state[Dimmable Light][devices]
list([
DeviceRegistryEntrySnapshot({

View File

@@ -383,6 +383,198 @@
'state': 'on',
})
# ---
# name: test_update_state[CS158-AF Air Fryer Cooking][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Cooking',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Cooking',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_update_state[CS158-AF Air Fryer Cooking][entities]
list([
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'update.cs158_af_air_fryer_cooking_firmware',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
'original_icon': None,
'original_name': 'Firmware',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'CS158Cooking',
'unit_of_measurement': None,
}),
])
# ---
# name: test_update_state[CS158-AF Air Fryer Cooking][update.cs158_af_air_fryer_cooking_firmware]
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'friendly_name': 'CS158-AF Air Fryer Cooking Firmware',
'in_progress': False,
'installed_version': None,
'latest_version': None,
'release_summary': None,
'release_url': None,
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 0>,
'title': None,
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.cs158_af_air_fryer_cooking_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_update_state[CS158-AF Air Fryer Standby][devices]
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'vesync',
'CS158Standby',
),
}),
'labels': set({
}),
'manufacturer': 'VeSync',
'model': 'CS158-AF',
'model_id': None,
'name': 'CS158-AF Air Fryer Standby',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
}),
])
# ---
# name: test_update_state[CS158-AF Air Fryer Standby][entities]
list([
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'update.cs158_af_air_fryer_standby_firmware',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
'original_icon': None,
'original_name': 'Firmware',
'platform': 'vesync',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'CS158Standby',
'unit_of_measurement': None,
}),
])
# ---
# name: test_update_state[CS158-AF Air Fryer Standby][update.cs158_af_air_fryer_standby_firmware]
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': 'https://brands.home-assistant.io/_/vesync/icon.png',
'friendly_name': 'CS158-AF Air Fryer Standby Firmware',
'in_progress': False,
'installed_version': None,
'latest_version': None,
'release_summary': None,
'release_url': None,
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 0>,
'title': None,
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.cs158_af_air_fryer_standby_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_update_state[Dimmable Light][devices]
list([
DeviceRegistryEntrySnapshot({

View File

@@ -12,3 +12,4 @@ TEST_PASSWORD = "fake_password"
TEST_TYPE = DeviceType.SERCOMM
TEST_URL = f"https://{TEST_HOST}"
TEST_USERNAME = "fake_username"
TEST_SERIAL_NUMBER = "m123456789"

View File

@@ -19,6 +19,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .const import TEST_SERIAL_NUMBER
from tests.common import MockConfigEntry, snapshot_platform
@@ -51,7 +52,7 @@ async def test_pressing_button(
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"},
{ATTR_ENTITY_ID: f"button.vodafone_station_{TEST_SERIAL_NUMBER}_restart"},
blocking=True,
)
mock_vodafone_station_router.restart_router.assert_called_once()
@@ -84,7 +85,7 @@ async def test_button_fails(
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: "button.vodafone_station_m123456789_restart"},
{ATTR_ENTITY_ID: f"button.vodafone_station_{TEST_SERIAL_NUMBER}_restart"},
blocking=True,
)

View File

@@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .const import TEST_SERIAL_NUMBER
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -52,7 +53,9 @@ async def test_active_connection_type(
"""Test device connection type."""
await setup_integration(hass, mock_config_entry)
active_connection_entity = "sensor.vodafone_station_m123456789_active_connection"
active_connection_entity = (
f"sensor.vodafone_station_{TEST_SERIAL_NUMBER}_active_connection"
)
assert (state := hass.states.get(active_connection_entity))
assert state.state == STATE_UNKNOWN
@@ -80,7 +83,7 @@ async def test_uptime(
await setup_integration(hass, mock_config_entry)
uptime = "2024-11-19T20:19:00+00:00"
uptime_entity = "sensor.vodafone_station_m123456789_uptime"
uptime_entity = f"sensor.vodafone_station_{TEST_SERIAL_NUMBER}_uptime"
assert (state := hass.states.get(uptime_entity))
assert state.state == uptime
@@ -119,5 +122,7 @@ async def test_coordinator_client_connector_error(
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (state := hass.states.get("sensor.vodafone_station_m123456789_uptime"))
assert (
state := hass.states.get(f"sensor.vodafone_station_{TEST_SERIAL_NUMBER}_uptime")
)
assert state.state == STATE_UNAVAILABLE

View File

@@ -1207,19 +1207,19 @@ async def test_subscribe_triggers_no_triggers(
),
# Test verbose choose selector options
(
{CONF_ABOVE: {"chosen_selector": "entity", "entity": "sensor.test"}},
{CONF_ABOVE: {"active_choice": "entity", "entity": "sensor.test"}},
does_not_raise(),
),
(
{CONF_ABOVE: {"chosen_selector": "number", "number": 10}},
{CONF_ABOVE: {"active_choice": "number", "number": 10}},
does_not_raise(),
),
(
{CONF_BELOW: {"chosen_selector": "entity", "entity": "sensor.test"}},
{CONF_BELOW: {"active_choice": "entity", "entity": "sensor.test"}},
does_not_raise(),
),
(
{CONF_BELOW: {"chosen_selector": "number", "number": 90}},
{CONF_BELOW: {"active_choice": "number", "number": 90}},
does_not_raise(),
),
# Test invalid configurations
@@ -1235,7 +1235,7 @@ async def test_subscribe_triggers_no_triggers(
),
(
# Invalid choose selector option
{CONF_BELOW: {"chosen_selector": "cat", "cat": 90}},
{CONF_BELOW: {"active_choice": "cat", "cat": 90}},
pytest.raises(vol.Invalid),
),
],