Compare commits

..

71 Commits

Author SHA1 Message Date
Joost Lekkerkerker
3fe91751f5 Make add entry translatable (#159901) 2026-01-20 14:25:33 +01:00
Erwin Douna
6ee58b96ca Bump pyfirefly to 0.1.12 (#161278) 2026-01-20 13:51:09 +01:00
Erik Montnemery
d1404e7905 Simplify logic in condition tests (#161239) 2026-01-20 10:39:47 +01:00
Paulus Schoutsen
7c34191813 Use new app panel instead of ingress page (#161264)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-20 10:54:56 +02:00
PolarBearEs
7540d04779 Remove duplicated MQTT_ORIGIN_INFO_SCHEMA in schemas.py (#161263) 2026-01-20 08:41:40 +01:00
mettolen
d828130670 Bump pysaunum to 0.3.0 (#161255) 2026-01-20 08:31:48 +01:00
Adalberto Garcia Garces
2ec6c08bd7 Add 18 new Tuya device fixtures (#161225) 2026-01-20 07:33:59 +01:00
Thomas55555
48852bab7a Add ppb as a valid UOM for sensor/number SO2 device class (#159431) 2026-01-19 23:32:45 +00:00
Andres Ruiz
7d370f4513 Bump waterfurnace to 1.4.0 (#161244) 2026-01-19 20:33:29 +01:00
Aleksandr Oleinikov
9d97791faf Change default model for Ollama to qwen3:4b-instruct (#161202)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-19 12:56:18 -05:00
Norbert Rittel
4fe8982b68 Clarify description of lawn_mower.docked trigger (#161238) 2026-01-19 17:48:40 +00:00
Krisjanis Lejejs
8248ade211 Bump hass-nabucasa from 1.9.0 to 1.10.0 (#161226) 2026-01-19 15:02:09 +01:00
Brett Adams
572c0e393c Add reconfigure flow in Teslemetry (#160969)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:58:14 +01:00
Colin
d25f2bab9a Increase test coverage in openevse (#160971)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-19 14:44:05 +01:00
Dave Morra
916812dd58 Add trigger for vacuum returning to dock (#158143)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-19 13:34:35 +01:00
Marc Mueller
cea84aa3c8 Fix pytest usefixtures mark in proxmoxve tests (#161177) 2026-01-19 13:06:15 +01:00
Paulus Schoutsen
af83fa809a Add app panel (#157554)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-19 13:08:00 +02:00
epenet
8c997cb6a9 Add selenium to FORBIDDEN_PACKAGE_EXCEPTIONS (#161216) 2026-01-19 10:47:00 +01:00
Jacob
4ccb6e4c8b Fix icons for 'moving' state (#161194) 2026-01-19 10:37:19 +01:00
epenet
37a45b1a92 Use shorthand attributes in qwikswitch sensor/binary_sensor (#161209) 2026-01-19 10:26:21 +01:00
dependabot[bot]
ac84211702 Bump actions/ai-inference from 2.0.4 to 2.0.5 (#161206)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 10:25:42 +01:00
dependabot[bot]
c209ddbb24 Bump actions/cache from 5.0.1 to 5.0.2 (#161207)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 10:18:59 +01:00
epenet
66ab50c737 Fix incorrect device class in wirelesstag binary_sensor (#161215) 2026-01-19 10:14:36 +01:00
Artur Pragacz
46074b0f9c Fix color temperature attributes in wiz (#161125) 2026-01-19 09:35:36 +01:00
Przemko92
56d8913159 Bump compit-inext-api to 0.4.2 (#161162) 2026-01-19 09:33:55 +01:00
epenet
c1bbfec203 Use HassKey in wirelesstag (#161211) 2026-01-19 08:32:39 +01:00
epenet
290c2fd5b6 Use shorthand attributes in w800rf32 binary_sensor (#161210) 2026-01-19 08:32:12 +01:00
Tim Laing
e472180fb2 Bump pyicloud to 2.3.0 (#161164) 2026-01-18 17:36:50 +01:00
David(Xiaobin) Lin
a1ced9a259 Bump xiaomi-ble to 1.5.0 (#161154) 2026-01-18 17:34:51 +01:00
Daniel Hjelseth Høyer
80a700f668 Add more Tibber sensors (#161079)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-18 13:44:42 +01:00
mettolen
54fc963297 Add configurable sauna types to Saunum integration (#159782) 2026-01-18 13:43:11 +01:00
Joost Lekkerkerker
59776adeb3 Remove deprecated Homee entity (#161121) 2026-01-18 13:42:03 +01:00
Marc Mueller
af53daa43c Fix vicare DeprecationWarnings (#161161) 2026-01-18 13:40:56 +01:00
tronikos
65123609ea Bump opower to 0.16.4 (#161153) 2026-01-18 10:12:43 +01:00
Manu
847adcf977 Add tests for media player actions in Xbox integration (#161156) 2026-01-18 10:09:05 +01:00
Daniel Hjelseth Høyer
f0dc66cb53 Update Tibber library 0.35.0 (#161139) 2026-01-18 06:43:56 +01:00
Ivan Lopez Hernandez
54275a0ee4 Update Gemini SDK Version (#161137) 2026-01-17 15:21:22 -05:00
Manu
964f36bc50 Assume muted state in Xbox integration (#161118) 2026-01-17 21:05:53 +01:00
Erwin Douna
e83cbc3fc5 Proxmox set integration type (#161141) 2026-01-17 20:59:40 +01:00
Tero Paloheimo
e26d90d82b Bump xiaomi-ble to 1.4.3 (#161132) 2026-01-17 18:33:29 +01:00
Artur Pragacz
da52482365 Add labs to core files (#161126) 2026-01-17 17:01:59 +01:00
Maciej Bieniek
6ba16ee9e9 Create cable unplugged entity only for Shelly Flood Gen4 (#161053) 2026-01-17 16:58:23 +01:00
Glenn de Haan
fa29d8180f Improve quality scale to silver HDFury integration (#161077) 2026-01-17 16:57:25 +01:00
Zach Deibert
5d43efb22d Add support for Minecraft Server Java Edition 1.4 - 1.6 (#161035)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-17 16:14:17 +01:00
mettolen
3539c4bcec Update Saunum integration to platinum quality (#160824)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-17 15:44:45 +01:00
Allan Lewis
3e3ec4616c Update list of supported locations for London Air (#160884) 2026-01-17 15:44:34 +01:00
Steve Easley
907861effd Bump pyjvcprojector to 2.0.0 (#160739)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-17 15:44:29 +01:00
Zach Deibert
862a2bc95c Update mcstatus to 12.1.0 (#161124) 2026-01-17 15:41:47 +01:00
DeerMaximum
60f498c1fa Use configuration constants in NINA tests (#161119) 2026-01-17 14:22:32 +01:00
mettolen
bb3617ac08 Add switch entitles to Airobot integration (#161090) 2026-01-17 13:17:22 +01:00
Niracler
48d1bd13fa Add sensor platform support to sunricher_dali integration (#159579) 2026-01-17 13:16:43 +01:00
Josef Zweck
8555bc9da0 Add reauthentication to openai_conversation (#161044)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 13:13:58 +01:00
epenet
9260394883 Mark preset_mode type hints as compulsory in climate/fan platforms (#161043) 2026-01-17 13:09:18 +01:00
DeerMaximum
8503637a80 Patch the NINA library instead of the HTTP requests (#161074) 2026-01-17 13:00:40 +01:00
Erwin Douna
c993cd9bee Add Config Flow for ProxmoxVE (#142432)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-17 12:59:58 +01:00
epenet
171013c0d0 Improve type hints in nx584 (#161065) 2026-01-17 12:56:31 +01:00
Evan Graham
c8a7aa359e Add gpt-4.1-mini and gpt-4.1-nano to unsupported extended cache retention list (#161097) 2026-01-17 12:16:25 +01:00
Ludovic BOUÉ
88d8951657 Adjust battery voltage sensor display precision for Matter devices (#161088)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 11:55:44 +01:00
epenet
b66ab3cf92 Improve type hints in ness_alarm (#161064) 2026-01-17 11:43:14 +01:00
epenet
253b32abd6 Use HassKey in qwikswitch (#161066) 2026-01-17 00:33:30 +01:00
Ludovic BOUÉ
cc20072c86 Fix Matter Window covering config status entity name (#160960) 2026-01-17 00:25:48 +01:00
Bram Kragten
f86db56d48 Update frontend to 20260107.2 (#161061) 2026-01-16 16:44:08 +01:00
Manu
3e2ebb8ebb Fix entity description in Mastodon (#161068) 2026-01-16 16:38:00 +01:00
Artur Pragacz
6e7b206788 Add update preview feature to labs (#160989) 2026-01-16 15:18:06 +01:00
Manu
cee007b0b0 Add binary sensor platform to Mastodon (#161056) 2026-01-16 14:31:42 +01:00
Erwin Douna
bd24c27bc9 SMA add selector strings/translation (#161060) 2026-01-16 13:56:15 +01:00
Andrew Jackson
49bd26da86 Bump aiomealie to 1.2.0 (#161058) 2026-01-16 13:37:22 +01:00
AlCalzone
49c42b9ad0 Clean up unnecessary Z-Wave "device config changed" repairs (#161000) 2026-01-16 12:51:42 +01:00
Josef Zweck
411491dc45 Type OpenAI config entry consistently (#161052) 2026-01-16 11:19:51 +01:00
Erik Montnemery
47383a499e Remove useless @pytest.mark.asyncio decorators from tests (#161050) 2026-01-16 10:19:23 +01:00
Erwin Douna
f9aa307cb2 SMA add reconfigure flow (#160743)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-16 10:16:34 +01:00
275 changed files with 25745 additions and 13956 deletions

View File

@@ -91,6 +91,7 @@ components: &components
- homeassistant/components/input_number/**
- homeassistant/components/input_select/**
- homeassistant/components/input_text/**
- homeassistant/components/labs/**
- homeassistant/components/logbook/**
- homeassistant/components/logger/**
- homeassistant/components/lovelace/**

View File

@@ -310,7 +310,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 actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
key: &key-python-venv >-
@@ -374,7 +374,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -425,7 +425,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
fail-on-cache-miss: true

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
with:
model: openai/gpt-4o-mini
system-prompt: |

View File

@@ -455,6 +455,7 @@ homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.*
homeassistant.components.saunum.*
homeassistant.components.scene.*
homeassistant.components.schedule.*
homeassistant.components.schlage.*

7
CODEOWNERS generated
View File

@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/tests/components/mill/ @danielhiversen
/homeassistant/components/min_max/ @gjohansson-ST
/tests/components/min_max/ @gjohansson-ST
/homeassistant/components/minecraft_server/ @elmurato
/tests/components/minecraft_server/ @elmurato
/homeassistant/components/minecraft_server/ @elmurato @zachdeibert
/tests/components/minecraft_server/ @elmurato @zachdeibert
/homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan
/homeassistant/components/moat/ @bdraco
@@ -1273,7 +1273,8 @@ build.json @home-assistant/supervisor
/tests/components/prosegur/ @dgomes
/homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato

View File

@@ -52,7 +52,7 @@ class AdGuardHomeEntity(Entity):
def device_info(self) -> DeviceInfo:
"""Return device information about this AdGuard Home instance."""
if self._entry.source == SOURCE_HASSIO:
config_url = "homeassistant://hassio/ingress/a0d7b954_adguard"
config_url = "homeassistant://app/a0d7b954_adguard"
elif self.adguard.tls:
config_url = f"https://{self.adguard.host}:{self.adguard.port}"
else:

View File

@@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -63,6 +63,11 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
_attr_min_temp = SETPOINT_TEMP_MIN
_attr_max_temp = SETPOINT_TEMP_MAX
def __init__(self, coordinator) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.data.status.device_id
@property
def _status(self) -> ThermostatStatus:
"""Get status from coordinator data."""

View File

@@ -24,8 +24,6 @@ class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
status = coordinator.data.status
settings = coordinator.data.settings
self._attr_unique_id = status.device_id
connections = set()
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
connections.add((CONNECTION_NETWORK_MAC, mac))

View File

@@ -9,6 +9,14 @@
"hysteresis_band": {
"default": "mdi:delta"
}
},
"switch": {
"actuator_exercise_disabled": {
"default": "mdi:valve"
},
"child_lock": {
"default": "mdi:lock"
}
}
}
}

View File

@@ -85,6 +85,14 @@
"heating_uptime": {
"name": "Heating uptime"
}
},
"switch": {
"actuator_exercise_disabled": {
"name": "Actuator exercise disabled"
},
"child_lock": {
"name": "Child lock"
}
}
},
"exceptions": {
@@ -105,6 +113,12 @@
},
"set_value_failed": {
"message": "Failed to set value: {error}"
},
"switch_turn_off_failed": {
"message": "Failed to turn off {switch}."
},
"switch_turn_on_failed": {
"message": "Failed to turn on {switch}."
}
}
}

View File

@@ -0,0 +1,118 @@
"""Switch platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pyairobotrest.exceptions import AirobotError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirobotConfigEntry
from .const import DOMAIN
from .coordinator import AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotSwitchEntityDescription(SwitchEntityDescription):
"""Describes Airobot switch entity."""
is_on_fn: Callable[[AirobotDataUpdateCoordinator], bool]
turn_on_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
SWITCH_TYPES: tuple[AirobotSwitchEntityDescription, ...] = (
AirobotSwitchEntityDescription(
key="child_lock",
translation_key="child_lock",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda coordinator: (
coordinator.data.settings.setting_flags.childlock_enabled
),
turn_on_fn=lambda coordinator: coordinator.client.set_child_lock(True),
turn_off_fn=lambda coordinator: coordinator.client.set_child_lock(False),
),
AirobotSwitchEntityDescription(
key="actuator_exercise_disabled",
translation_key="actuator_exercise_disabled",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
is_on_fn=lambda coordinator: (
coordinator.data.settings.setting_flags.actuator_exercise_disabled
),
turn_on_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
True
),
turn_off_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
False
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot switch entities."""
coordinator = entry.runtime_data
async_add_entities(
AirobotSwitch(coordinator, description) for description in SWITCH_TYPES
)
class AirobotSwitch(AirobotEntity, SwitchEntity):
"""Representation of an Airobot switch."""
entity_description: AirobotSwitchEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
try:
await self.entity_description.turn_on_fn(self.coordinator)
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_turn_on_failed",
translation_placeholders={"switch": self.entity_description.key},
) from err
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
try:
await self.entity_description.turn_off_fn(self.coordinator)
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_turn_off_failed",
translation_placeholders={"switch": self.entity_description.key},
) from err
await self.coordinator.async_request_refresh()

View File

@@ -85,9 +85,9 @@
}
},
"moving": {
"default": "mdi:arrow-right",
"default": "mdi:octagon",
"state": {
"on": "mdi:octagon"
"on": "mdi:arrow-right"
}
},
"occupancy": {

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.9.0"],
"requirements": ["hass-nabucasa==1.10.0"],
"single_config_entry": true
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.3.4"]
"requirements": ["compit-inext-api==0.4.2"]
}

View File

@@ -10,7 +10,7 @@ LOGGER = logging.getLogger(__package__)
DOMAIN = "deconz"
HASSIO_CONFIGURATION_URL = "homeassistant://hassio/ingress/core_deconz"
HASSIO_CONFIGURATION_URL = "homeassistant://app/core_deconz"
CONF_BRIDGE_ID = "bridgeid"
CONF_GROUP_ID_BASE = "group_id_base"

View File

@@ -1034,7 +1034,7 @@ def _async_setup_device_registry(
and dashboard.data
and dashboard.data.get(device_info.name)
):
configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
configuration_url = f"homeassistant://app/{dashboard.addon_slug}"
manufacturer = "espressif"
if device_info.manufacturer:

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.11"]
"requirements": ["pyfirefly==0.1.12"]
}

View File

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

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-genai==1.56.0"]
"requirements": ["google-genai==1.59.0"]
}

View File

@@ -16,7 +16,7 @@ from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components import panel_custom
from homeassistant.components import frontend, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
@@ -292,6 +292,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
return False
async_load_websocket_api(hass)
frontend.async_register_built_in_panel(hass, "app")
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)

View File

@@ -19,6 +19,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class HDFuryButtonEntityDescription(ButtonEntityDescription):

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hdfury",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["hdfury==1.3.1"]
}

View File

@@ -35,11 +35,11 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Integration has no authentication flow.
test-coverage: todo
test-coverage: done
# Gold
devices: done

View File

@@ -20,6 +20,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
from .entity import HDFuryEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class HDFurySelectEntityDescription(SelectEntityDescription):
@@ -77,13 +79,11 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entities: list[HDFuryEntity] = []
for description in SELECT_PORTS:
if description.key not in coordinator.data.info:
continue
entities.append(HDFurySelect(coordinator, description))
entities: list[HDFuryEntity] = [
HDFurySelect(coordinator, description)
for description in SELECT_PORTS
if description.key in coordinator.data.info
]
# Add OPMODE select if present
if "opmode" in coordinator.data.info:

View File

@@ -8,6 +8,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
PARALLEL_UPDATES = 0
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="RX0",

View File

@@ -16,6 +16,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class HDFurySwitchEntityDescription(SwitchEntityDescription):

View File

@@ -6,10 +6,7 @@ from dataclasses import dataclass
from pyHomee.const import AttributeType, NodeState
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -17,17 +14,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import HomeeConfigEntry
from .const import (
DOMAIN,
HOMEE_UNIT_TO_HA_UNIT,
OPEN_CLOSE_MAP,
OPEN_CLOSE_MAP_REVERSED,
@@ -109,11 +99,6 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription(
key="valve_position",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.DAWN: HomeeSensorEntityDescription(
key="dawn",
device_class=SensorDeviceClass.ILLUMINANCE,
@@ -294,57 +279,12 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
)
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Get list of related automations and scripts."""
used_in = automations_with_entity(hass, entity_id)
used_in += scripts_with_entity(hass, entity_id)
return used_in
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the sensor components."""
ent_reg = er.async_get(hass)
def add_deprecated_entity(
attribute: HomeeAttribute, description: HomeeSensorEntityDescription
) -> list[HomeeSensor]:
"""Add deprecated entities."""
deprecated_entities: list[HomeeSensor] = []
entity_uid = f"{config_entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}"
if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, entity_uid):
entity_entry = ent_reg.async_get(entity_id)
if entity_entry and entity_entry.disabled:
ent_reg.async_remove(entity_id)
async_delete_issue(
hass,
DOMAIN,
f"deprecated_entity_{entity_uid}",
)
elif entity_entry:
deprecated_entities.append(
HomeeSensor(attribute, config_entry, description)
)
if entity_used_in(hass, entity_id):
async_create_issue(
hass,
DOMAIN,
f"deprecated_entity_{entity_uid}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"name": str(
entity_entry.name or entity_entry.original_name
),
"entity": entity_id,
},
)
return deprecated_entities
async def add_sensor_entities(
config_entry: HomeeConfigEntry,
@@ -362,19 +302,13 @@ async def async_setup_entry(
)
# Node attributes that are sensors.
for attribute in node.attributes:
if attribute.type == AttributeType.CURRENT_VALVE_POSITION:
entities.extend(
add_deprecated_entity(
attribute, SENSOR_DESCRIPTIONS[attribute.type]
)
)
elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable:
entities.append(
HomeeSensor(
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
)
)
entities.extend(
HomeeSensor(
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
)
for attribute in node.attributes
if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable
)
if entities:
async_add_entities(entities)

View File

@@ -495,11 +495,5 @@
"invalid_preset_mode": {
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
}
},
"issues": {
"deprecated_entity": {
"description": "The Homee entity `{entity}` is deprecated and will be removed in release 2025.12.\nThe valve is available directly in the respective climate entity.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",
"title": "The Homee {name} entity is deprecated"
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.2.0"]
"requirements": ["pyicloud==2.3.0"]
}

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorTimeoutError
from homeassistant.const import (
CONF_HOST,
@@ -11,8 +11,9 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
@@ -28,8 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool:
)
try:
await device.connect(True)
except JvcProjectorConnectError as err:
await device.connect()
except JvcProjectorTimeoutError as err:
await device.disconnect()
raise ConfigEntryNotReady(
f"Unable to connect to {entry.data[CONF_HOST]}"
@@ -50,6 +51,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect)
)
await async_migrate_entities(hass, entry, coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -60,3 +63,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.device.disconnect()
return unload_ok
async def async_migrate_entities(
hass: HomeAssistant,
config_entry: JVCConfigEntry,
coordinator: JvcProjectorDataUpdateCoordinator,
) -> None:
"""Migrate old entities as needed."""
@callback
def _update_entry(entry: RegistryEntry) -> dict[str, str] | None:
"""Fix unique_id of power binary_sensor entry."""
if entry.domain == Platform.BINARY_SENSOR and ":" not in entry.unique_id:
if "_power" in entry.unique_id:
return {"new_unique_id": f"{coordinator.unique_id}_power"}
return None
await async_migrate_entries(hass, config_entry.entry_id, _update_entry)

View File

@@ -2,16 +2,17 @@
from __future__ import annotations
from jvcprojector import const
from jvcprojector import command as cmd
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import POWER
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
ON_STATUS = (const.ON, const.WARMING)
ON_STATUS = (cmd.Power.ON, cmd.Power.WARMING)
async def async_setup_entry(
@@ -21,14 +22,13 @@ async def async_setup_entry(
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
async_add_entities([JvcBinarySensor(coordinator)])
class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity):
"""The entity class for JVC Projector Binary Sensor."""
_attr_translation_key = "jvc_power"
_attr_translation_key = "power"
def __init__(
self,
@@ -36,9 +36,9 @@ class JvcBinarySensor(JvcProjectorEntity, BinarySensorEntity):
) -> None:
"""Initialize the JVC Projector sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.device.mac}_power"
self._attr_unique_id = f"{coordinator.unique_id}_power"
@property
def is_on(self) -> bool:
"""Return true if the JVC is on."""
return self.coordinator.data["power"] in ON_STATUS
"""Return true if the JVC Projector is on."""
return self.coordinator.data[POWER] in ON_STATUS

View File

@@ -5,7 +5,12 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError
from jvcprojector import (
JvcProjector,
JvcProjectorAuthError,
JvcProjectorTimeoutError,
command as cmd,
)
from jvcprojector.projector import DEFAULT_PORT
import voluptuous as vol
@@ -40,7 +45,7 @@ class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN):
mac = await get_mac_address(host, port, password)
except InvalidHost:
errors["base"] = "invalid_host"
except JvcProjectorConnectError:
except JvcProjectorTimeoutError:
errors["base"] = "cannot_connect"
except JvcProjectorAuthError:
errors["base"] = "invalid_auth"
@@ -91,7 +96,7 @@ class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN):
try:
await get_mac_address(host, port, password)
except JvcProjectorConnectError:
except JvcProjectorTimeoutError:
errors["base"] = "cannot_connect"
except JvcProjectorAuthError:
errors["base"] = "invalid_auth"
@@ -115,7 +120,7 @@ async def get_mac_address(host: str, port: int, password: str | None) -> str:
"""Get device mac address for config flow."""
device = JvcProjector(host, port=port, password=password)
try:
await device.connect(True)
await device.connect()
return await device.get(cmd.MacAddress)
finally:
await device.disconnect()
return device.mac

View File

@@ -3,3 +3,7 @@
NAME = "JVC Projector"
DOMAIN = "jvc_projector"
MANUFACTURER = "JVC"
POWER = "power"
INPUT = "input"
SOURCE = "source"

View File

@@ -4,22 +4,21 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from jvcprojector import (
JvcProjector,
JvcProjectorAuthError,
JvcProjectorConnectError,
const,
JvcProjectorTimeoutError,
command as cmd,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import NAME
from .const import INPUT, NAME, POWER
_LOGGER = logging.getLogger(__name__)
@@ -46,26 +45,33 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
update_interval=INTERVAL_SLOW,
)
self.device = device
self.unique_id = format_mac(device.mac)
self.device: JvcProjector = device
if TYPE_CHECKING:
assert config_entry.unique_id is not None
self.unique_id = config_entry.unique_id
async def _async_update_data(self) -> dict[str, Any]:
"""Get the latest state data."""
state: dict[str, str | None] = {
POWER: None,
INPUT: None,
}
try:
state = await self.device.get_state()
except JvcProjectorConnectError as err:
state[POWER] = await self.device.get(cmd.Power)
if state[POWER] == cmd.Power.ON:
state[INPUT] = await self.device.get(cmd.Input)
except JvcProjectorTimeoutError as err:
raise UpdateFailed(f"Unable to connect to {self.device.host}") from err
except JvcProjectorAuthError as err:
raise ConfigEntryAuthFailed("Password authentication failed") from err
old_interval = self.update_interval
if state[const.POWER] != const.STANDBY:
if state[POWER] != cmd.Power.STANDBY:
self.update_interval = INTERVAL_FAST
else:
self.update_interval = INTERVAL_SLOW
if self.update_interval != old_interval:
_LOGGER.debug("Changed update interval to %s", self.update_interval)
return state

View File

@@ -26,7 +26,7 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
self._attr_unique_id = coordinator.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.unique_id)},
identifiers={(DOMAIN, self._attr_unique_id)},
name=NAME,
model=self.device.model,
manufacturer=MANUFACTURER,

View File

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

View File

@@ -7,54 +7,62 @@ from collections.abc import Iterable
import logging
from typing import Any
from jvcprojector import const
from jvcprojector import command as cmd
from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import POWER
from .coordinator import JVCConfigEntry
from .entity import JvcProjectorEntity
COMMANDS = {
"menu": const.REMOTE_MENU,
"up": const.REMOTE_UP,
"down": const.REMOTE_DOWN,
"left": const.REMOTE_LEFT,
"right": const.REMOTE_RIGHT,
"ok": const.REMOTE_OK,
"back": const.REMOTE_BACK,
"mpc": const.REMOTE_MPC,
"hide": const.REMOTE_HIDE,
"info": const.REMOTE_INFO,
"input": const.REMOTE_INPUT,
"cmd": const.REMOTE_CMD,
"advanced_menu": const.REMOTE_ADVANCED_MENU,
"picture_mode": const.REMOTE_PICTURE_MODE,
"color_profile": const.REMOTE_COLOR_PROFILE,
"lens_control": const.REMOTE_LENS_CONTROL,
"setting_memory": const.REMOTE_SETTING_MEMORY,
"gamma_settings": const.REMOTE_GAMMA_SETTINGS,
"hdmi_1": const.REMOTE_HDMI_1,
"hdmi_2": const.REMOTE_HDMI_2,
"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,
"natural": const.REMOTE_NATURAL,
"cinema": const.REMOTE_CINEMA,
"anamo": const.REMOTE_ANAMO,
"3d_format": const.REMOTE_3D_FORMAT,
COMMANDS: list[str] = [
cmd.Remote.MENU,
cmd.Remote.UP,
cmd.Remote.DOWN,
cmd.Remote.LEFT,
cmd.Remote.RIGHT,
cmd.Remote.OK,
cmd.Remote.BACK,
cmd.Remote.MPC,
cmd.Remote.HIDE,
cmd.Remote.INFO,
cmd.Remote.INPUT,
cmd.Remote.CMD,
cmd.Remote.ADVANCED_MENU,
cmd.Remote.PICTURE_MODE,
cmd.Remote.COLOR_PROFILE,
cmd.Remote.LENS_CONTROL,
cmd.Remote.SETTING_MEMORY,
cmd.Remote.GAMMA_SETTINGS,
cmd.Remote.HDMI1,
cmd.Remote.HDMI2,
cmd.Remote.MODE_1,
cmd.Remote.MODE_2,
cmd.Remote.MODE_3,
cmd.Remote.MODE_4,
cmd.Remote.MODE_5,
cmd.Remote.MODE_6,
cmd.Remote.MODE_7,
cmd.Remote.MODE_8,
cmd.Remote.MODE_9,
cmd.Remote.MODE_10,
cmd.Remote.GAMMA,
cmd.Remote.NATURAL,
cmd.Remote.CINEMA,
cmd.Remote.COLOR_TEMP,
cmd.Remote.ANAMORPHIC,
cmd.Remote.LENS_APERTURE,
cmd.Remote.V3D_FORMAT,
]
RENAMED_COMMANDS: dict[str, str] = {
"anamo": cmd.Remote.ANAMORPHIC,
"lens_ap": cmd.Remote.LENS_APERTURE,
"hdmi1": cmd.Remote.HDMI1,
"hdmi2": cmd.Remote.HDMI2,
}
_LOGGER = logging.getLogger(__name__)
@@ -77,25 +85,34 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity):
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.coordinator.data["power"] in [const.ON, const.WARMING]
"""Return True if the entity is on."""
return self.coordinator.data[POWER] in (cmd.Power.ON, cmd.Power.WARMING)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await self.device.power_on()
await self.device.set(cmd.Power, cmd.Power.ON)
await asyncio.sleep(1)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await self.device.power_off()
await self.device.set(cmd.Power, cmd.Power.OFF)
await asyncio.sleep(1)
await self.coordinator.async_refresh()
async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a remote command to the device."""
for cmd in command:
if cmd not in COMMANDS:
raise HomeAssistantError(f"{cmd} is not a known command")
_LOGGER.debug("Sending command '%s'", cmd)
await self.device.remote(COMMANDS[cmd])
for send_command in command:
# Legacy name replace
if send_command in RENAMED_COMMANDS:
send_command = RENAMED_COMMANDS[send_command]
# Legacy name fixup
if "_" in send_command:
send_command = send_command.replace("_", "-")
if send_command not in COMMANDS:
raise HomeAssistantError(f"{send_command} is not a known command")
_LOGGER.debug("Sending command '%s'", send_command)
await self.device.remote(send_command)

View File

@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Final
from jvcprojector import JvcProjector, const
from jvcprojector import JvcProjector, command as cmd
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
@@ -23,16 +23,12 @@ class JvcProjectorSelectDescription(SelectEntityDescription):
command: Callable[[JvcProjector, str], Awaitable[None]]
OPTIONS: Final[dict[str, dict[str, str]]] = {
"input": {const.HDMI1: const.REMOTE_HDMI_1, const.HDMI2: const.REMOTE_HDMI_2}
}
SELECTS: Final[list[JvcProjectorSelectDescription]] = [
JvcProjectorSelectDescription(
key="input",
translation_key="input",
options=list(OPTIONS["input"]),
command=lambda device, option: device.remote(OPTIONS["input"][option]),
options=[cmd.Input.HDMI1, cmd.Input.HDMI2],
command=lambda device, option: device.set(cmd.Input, option),
)
]

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from jvcprojector import const
from jvcprojector import command as cmd
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -23,11 +23,11 @@ JVC_SENSORS = (
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[
const.STANDBY,
const.ON,
const.WARMING,
const.COOLING,
const.ERROR,
cmd.Power.STANDBY,
cmd.Power.ON,
cmd.Power.WARMING,
cmd.Power.COOLING,
cmd.Power.ERROR,
],
),
)

View File

@@ -35,7 +35,7 @@
},
"entity": {
"binary_sensor": {
"jvc_power": {
"power": {
"name": "[%key:component::binary_sensor::entity_component::power::name%]"
}
},
@@ -50,7 +50,7 @@
},
"sensor": {
"jvc_power_status": {
"name": "Power status",
"name": "Status",
"state": {
"cooling": "Cooling",
"error": "[%key:common::state::error%]",

View File

@@ -18,7 +18,11 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_custom_components
from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
from .helpers import async_is_preview_feature_enabled, async_listen
from .helpers import (
async_is_preview_feature_enabled,
async_listen,
async_update_preview_feature,
)
from .models import (
EventLabsUpdatedData,
LabPreviewFeature,
@@ -37,6 +41,7 @@ __all__ = [
"EventLabsUpdatedData",
"async_is_preview_feature_enabled",
"async_listen",
"async_update_preview_feature",
]

View File

@@ -61,3 +61,32 @@ def async_listen(
listener()
return hass.bus.async_listen(EVENT_LABS_UPDATED, _async_feature_updated)
async def async_update_preview_feature(
hass: HomeAssistant,
domain: str,
preview_feature: str,
enabled: bool,
) -> None:
"""Update a lab preview feature state."""
labs_data = hass.data[LABS_DATA]
preview_feature_id = f"{domain}.{preview_feature}"
if preview_feature_id not in labs_data.preview_features:
raise ValueError(f"Preview feature {preview_feature_id} not found")
if enabled:
labs_data.data.preview_feature_status.add((domain, preview_feature))
else:
labs_data.data.preview_feature_status.discard((domain, preview_feature))
await labs_data.store.async_save(labs_data.data.to_store_format())
event_data: EventLabsUpdatedData = {
"domain": domain,
"preview_feature": preview_feature,
"enabled": enabled,
}
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)

View File

@@ -8,12 +8,14 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.backup import async_get_manager
from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import HomeAssistant, callback
from .const import LABS_DATA
from .helpers import async_is_preview_feature_enabled, async_listen
from .models import EventLabsUpdatedData
from .helpers import (
async_is_preview_feature_enabled,
async_listen,
async_update_preview_feature,
)
@callback
@@ -95,19 +97,7 @@ async def websocket_update_preview_feature(
)
return
if enabled:
labs_data.data.preview_feature_status.add((domain, preview_feature))
else:
labs_data.data.preview_feature_status.discard((domain, preview_feature))
await labs_data.store.async_save(labs_data.data.to_store_format())
event_data: EventLabsUpdatedData = {
"domain": domain,
"preview_feature": preview_feature,
"enabled": enabled,
}
hass.bus.async_fire(EVENT_LABS_UPDATED, event_data)
await async_update_preview_feature(hass, domain, preview_feature, enabled)
connection.send_result(msg["id"])

View File

@@ -41,7 +41,7 @@
"title": "Lawn mower",
"triggers": {
"docked": {
"description": "Triggers after one or more lawn mowers return to dock.",
"description": "Triggers after one or more lawn mowers have returned to dock.",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",

View File

@@ -1,47 +1,24 @@
"""Provides triggers for lights."""
from typing import Any
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
Trigger,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
from . import ATTR_BRIGHTNESS
from .const import DOMAIN
def _convert_uint8_to_percentage(value: Any) -> float:
"""Convert a uint8 value (0-255) to a percentage (0-100)."""
return (float(value) / 255.0) * 100.0
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domain = DOMAIN
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)
class BrightnessCrossedThresholdTrigger(
EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for brightness crossed threshold."""
_domain = DOMAIN
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)
TRIGGERS: dict[str, type[Trigger]] = {
"brightness_changed": BrightnessChangedTrigger,
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
"brightness_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_BRIGHTNESS
),
"brightness_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_BRIGHTNESS
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -22,10 +22,7 @@
number:
selector:
number:
max: 100
min: 0
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:

View File

@@ -27,6 +27,7 @@ SCAN_INTERVAL = timedelta(minutes=30)
AUTHORITIES = [
"Barking and Dagenham",
"Barnet",
"Bexley",
"Brent",
"Bromley",
@@ -49,11 +50,13 @@ AUTHORITIES = [
"Lambeth",
"Lewisham",
"Merton",
"Newham",
"Redbridge",
"Richmond",
"Southwark",
"Sutton",
"Tower Hamlets",
"Waltham Forest",
"Wandsworth",
"Westminster",
]

View File

@@ -28,7 +28,7 @@ from .coordinator import MastodonConfigEntry, MastodonCoordinator, MastodonData
from .services import async_setup_services
from .utils import construct_mastodon_username, create_mastodon_client
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

View File

@@ -0,0 +1,128 @@
"""Binary sensor platform for the Mastodon integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from mastodon.Mastodon import Account
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MastodonConfigEntry
from .entity import MastodonEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
class MastodonBinarySensor(StrEnum):
"""Mastodon binary sensors."""
BOT = "bot"
SUSPENDED = "suspended"
DISCOVERABLE = "discoverable"
LOCKED = "locked"
INDEXABLE = "indexable"
LIMITED = "limited"
MEMORIAL = "memorial"
MOVED = "moved"
@dataclass(frozen=True, kw_only=True)
class MastodonBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Mastodon binary sensor description."""
is_on_fn: Callable[[Account], bool | None]
ENTITY_DESCRIPTIONS: tuple[MastodonBinarySensorEntityDescription, ...] = (
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.BOT,
translation_key=MastodonBinarySensor.BOT,
is_on_fn=lambda account: account.bot,
entity_category=EntityCategory.DIAGNOSTIC,
),
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.DISCOVERABLE,
translation_key=MastodonBinarySensor.DISCOVERABLE,
is_on_fn=lambda account: account.discoverable,
entity_category=EntityCategory.DIAGNOSTIC,
),
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.LOCKED,
translation_key=MastodonBinarySensor.LOCKED,
is_on_fn=lambda account: account.locked,
entity_category=EntityCategory.DIAGNOSTIC,
),
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.MOVED,
translation_key=MastodonBinarySensor.MOVED,
is_on_fn=lambda account: account.moved is not None,
entity_category=EntityCategory.DIAGNOSTIC,
),
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.INDEXABLE,
translation_key=MastodonBinarySensor.INDEXABLE,
is_on_fn=lambda account: account.indexable,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.LIMITED,
translation_key=MastodonBinarySensor.LIMITED,
is_on_fn=lambda account: account.limited is True,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.MEMORIAL,
translation_key=MastodonBinarySensor.MEMORIAL,
is_on_fn=lambda account: account.memorial is True,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
MastodonBinarySensorEntityDescription(
key=MastodonBinarySensor.SUSPENDED,
translation_key=MastodonBinarySensor.SUSPENDED,
is_on_fn=lambda account: account.suspended is True,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MastodonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
MastodonBinarySensorEntity(
coordinator=coordinator,
entity_description=entity_description,
data=entry,
)
for entity_description in ENTITY_DESCRIPTIONS
)
class MastodonBinarySensorEntity(MastodonEntity, BinarySensorEntity):
"""Mastodon binary sensor entity."""
entity_description: MastodonBinarySensorEntityDescription
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.data)

View File

@@ -1,5 +1,18 @@
{
"entity": {
"binary_sensor": {
"bot": { "default": "mdi:robot" },
"discoverable": { "default": "mdi:magnify-scan" },
"indexable": { "default": "mdi:search-web" },
"limited": { "default": "mdi:account-cancel" },
"locked": {
"default": "mdi:account-lock",
"state": { "off": "mdi:account-lock-open" }
},
"memorial": { "default": "mdi:candle" },
"moved": { "default": "mdi:truck-delivery" },
"suspended": { "default": "mdi:account-off" }
},
"sensor": {
"followers": {
"default": "mdi:account-multiple"

View File

@@ -26,6 +26,16 @@
}
},
"entity": {
"binary_sensor": {
"bot": { "name": "Bot" },
"discoverable": { "name": "Discoverable" },
"indexable": { "name": "Indexable" },
"limited": { "name": "Limited" },
"locked": { "name": "Locked" },
"memorial": { "name": "Memorial" },
"moved": { "name": "Moved" },
"suspended": { "name": "Suspended" }
},
"sensor": {
"followers": {
"name": "Followers",

View File

@@ -489,6 +489,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="WindowCoveringConfigStatusOperational",
translation_key="config_status_operational",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# unset Operational bit from ConfigStatus bitmap means problem

View File

@@ -442,6 +442,9 @@ DISCOVERY_SCHEMAS = [
key="PowerSourceBatVoltage",
translation_key="battery_voltage",
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
# Battery voltages are low-voltage diagnostics; use 2 decimals in volts
# to provide finer granularity than mains-level voltage sensors.
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,

View File

@@ -56,6 +56,9 @@
"boost_state": {
"name": "Boost state"
},
"config_status_operational": {
"name": "Configuration status"
},
"dishwasher_alarm_inflow": {
"name": "Inflow alarm"
},

View File

@@ -192,7 +192,7 @@ class MaxCubeClimate(ClimateEntity):
self._set_target(None, temp)
@property
def preset_mode(self):
def preset_mode(self) -> str:
"""Return the current preset mode."""
if self._device.mode == MAX_DEVICE_MODE_MANUAL:
if self._device.target_temperature == self._device.comfort_temperature:

View File

@@ -50,7 +50,7 @@ class MealieConfigFlow(ConfigFlow, domain=DOMAIN):
"""Check connection to the Mealie API."""
assert self.host is not None
if "/hassio/ingress/" in self.host:
if "/app/" in self.host:
return {"base": "ingress_url"}, None
client = MealieClient(

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["aiomealie==1.1.1"]
"requirements": ["aiomealie==1.2.0"]
}

View File

@@ -5,8 +5,12 @@ from enum import StrEnum
import logging
from dns.resolver import LifetimeTimeout
from mcstatus import BedrockServer, JavaServer
from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse
from mcstatus import BedrockServer, JavaServer, LegacyServer
from mcstatus.responses import (
BedrockStatusResponse,
JavaStatusResponse,
LegacyStatusResponse,
)
from homeassistant.core import HomeAssistant
@@ -43,6 +47,7 @@ class MinecraftServerType(StrEnum):
BEDROCK_EDITION = "Bedrock Edition"
JAVA_EDITION = "Java Edition"
LEGACY_JAVA_EDITION = "Legacy Java Edition"
class MinecraftServerAddressError(Exception):
@@ -60,7 +65,7 @@ class MinecraftServerNotInitializedError(Exception):
class MinecraftServer:
"""Minecraft Server wrapper class for 3rd party library mcstatus."""
_server: BedrockServer | JavaServer | None
_server: BedrockServer | JavaServer | LegacyServer | None
def __init__(
self, hass: HomeAssistant, server_type: MinecraftServerType, address: str
@@ -76,10 +81,12 @@ class MinecraftServer:
try:
if self._server_type == MinecraftServerType.JAVA_EDITION:
self._server = await JavaServer.async_lookup(self._address)
else:
elif self._server_type == MinecraftServerType.BEDROCK_EDITION:
self._server = await self._hass.async_add_executor_job(
BedrockServer.lookup, self._address
)
else:
self._server = await LegacyServer.async_lookup(self._address)
except (ValueError, LifetimeTimeout) as error:
raise MinecraftServerAddressError(
f"Lookup of '{self._address}' failed: {self._get_error_message(error)}"
@@ -112,7 +119,9 @@ class MinecraftServer:
async def async_get_data(self) -> MinecraftServerData:
"""Get updated data from the server, supporting both Java and Bedrock Edition servers."""
status_response: BedrockStatusResponse | JavaStatusResponse
status_response: (
BedrockStatusResponse | JavaStatusResponse | LegacyStatusResponse
)
if self._server is None:
raise MinecraftServerNotInitializedError(
@@ -128,8 +137,10 @@ class MinecraftServer:
if isinstance(status_response, JavaStatusResponse):
data = self._extract_java_data(status_response)
else:
elif isinstance(status_response, BedrockStatusResponse):
data = self._extract_bedrock_data(status_response)
else:
data = self._extract_legacy_data(status_response)
return data
@@ -169,6 +180,19 @@ class MinecraftServer:
map_name=status_response.map_name,
)
def _extract_legacy_data(
self, status_response: LegacyStatusResponse
) -> MinecraftServerData:
"""Extract legacy Java Edition server data out of status response."""
return MinecraftServerData(
latency=status_response.latency,
motd=status_response.motd.to_plain(),
players_max=status_response.players.max,
players_online=status_response.players.online,
protocol_version=status_response.version.protocol,
version=status_response.version.name,
)
def _get_error_message(self, error: BaseException) -> str:
"""Get error message of an exception."""
if not str(error):

View File

@@ -84,4 +84,5 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
}
),
errors=errors,
description_placeholders={"minimum_minecraft_version": "1.4"},
)

View File

@@ -1,12 +1,12 @@
{
"domain": "minecraft_server",
"name": "Minecraft Server",
"codeowners": ["@elmurato"],
"codeowners": ["@elmurato", "@zachdeibert"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["dnspython", "mcstatus"],
"quality_scale": "silver",
"requirements": ["mcstatus==12.0.6"]
"requirements": ["mcstatus==12.1.0"]
}

View File

@@ -65,6 +65,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -76,6 +77,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -89,6 +91,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_registry_enabled_default=False,
),
@@ -102,6 +105,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
MinecraftServerType.LEGACY_JAVA_EDITION,
},
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -113,6 +117,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
MinecraftServerType.LEGACY_JAVA_EDITION,
},
),
MinecraftServerSensorEntityDescription(
@@ -124,6 +129,7 @@ SENSOR_DESCRIPTIONS = [
supported_server_types={
MinecraftServerType.JAVA_EDITION,
MinecraftServerType.BEDROCK_EDITION,
MinecraftServerType.LEGACY_JAVA_EDITION,
},
),
MinecraftServerSensorEntityDescription(

View File

@@ -4,7 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version 1.7."
"cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version {minimum_minecraft_version}."
},
"step": {
"user": {

View File

@@ -73,15 +73,6 @@ SHARED_OPTIONS = [
CONF_STATE_TOPIC,
]
MQTT_ORIGIN_INFO_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_SW_VERSION): cv.string,
vol.Optional(CONF_SUPPORT_URL): cv.configuration_url,
}
),
)
_MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema(
{

View File

@@ -1,8 +1,8 @@
"""Support for Ness D8X/D16X devices."""
from collections import namedtuple
import datetime
import logging
from typing import NamedTuple
from nessclient import ArmingMode, ArmingState, Client
import voluptuous as vol
@@ -25,11 +25,12 @@ from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
DOMAIN = "ness_alarm"
DATA_NESS = "ness_alarm"
DATA_NESS: HassKey[Client] = HassKey(DOMAIN)
CONF_DEVICE_PORT = "port"
CONF_INFER_ARMING_STATE = "infer_arming_state"
@@ -44,7 +45,13 @@ DEFAULT_INFER_ARMING_STATE = False
SIGNAL_ZONE_CHANGED = "ness_alarm.zone_changed"
SIGNAL_ARMING_STATE_CHANGED = "ness_alarm.arming_state_changed"
ZoneChangedData = namedtuple("ZoneChangedData", ["zone_id", "state"]) # noqa: PYI024
class ZoneChangedData(NamedTuple):
"""Data for a zone state change."""
zone_id: int
state: bool
DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION
ZONE_SCHEMA = vol.Schema(

View File

@@ -33,18 +33,14 @@ async def async_setup_platform(
configured_zones = discovery_info[CONF_ZONES]
devices = []
for zone_config in configured_zones:
zone_type = zone_config[CONF_ZONE_TYPE]
zone_name = zone_config[CONF_ZONE_NAME]
zone_id = zone_config[CONF_ZONE_ID]
device = NessZoneBinarySensor(
zone_id=zone_id, name=zone_name, zone_type=zone_type
async_add_entities(
NessZoneBinarySensor(
zone_id=zone_config[CONF_ZONE_ID],
name=zone_config[CONF_ZONE_NAME],
zone_type=zone_config[CONF_ZONE_TYPE],
)
devices.append(device)
async_add_entities(devices)
for zone_config in configured_zones
)
class NessZoneBinarySensor(BinarySensorEntity):
@@ -52,12 +48,14 @@ class NessZoneBinarySensor(BinarySensorEntity):
_attr_should_poll = False
def __init__(self, zone_id, name, zone_type):
def __init__(
self, zone_id: int, name: str, zone_type: BinarySensorDeviceClass
) -> None:
"""Initialize the binary_sensor."""
self._zone_id = zone_id
self._name = name
self._type = zone_type
self._state = 0
self._attr_name = name
self._attr_device_class = zone_type
self._attr_is_on = False
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -67,24 +65,9 @@ class NessZoneBinarySensor(BinarySensorEntity):
)
)
@property
def name(self):
"""Return the name of the entity."""
return self._name
@property
def is_on(self):
"""Return true if sensor is on."""
return self._state == 1
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._type
@callback
def _handle_zone_change(self, data: ZoneChangedData):
def _handle_zone_change(self, data: ZoneChangedData) -> None:
"""Handle zone state update."""
if self._zone_id == data.zone_id:
self._state = data.state
self._attr_is_on = data.state
self.async_write_ha_state()

View File

@@ -225,7 +225,7 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
self._signal_thermostat_update()
@property
def preset_mode(self):
def preset_mode(self) -> str | None:
"""Preset that is active."""
return self._zone.get_preset()

View File

@@ -47,10 +47,8 @@ rules:
test-coverage:
status: todo
comment: |
Patch the library instead of the HTTP requests
Create a shared fixture for the mock config entry
Use init_integration in tests
Evaluate the need of test_config_entry_not_ready
# Gold
devices: done

View File

@@ -154,7 +154,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity):
return nuheat_to_fahrenheit(self._target_temperature)
@property
def preset_mode(self):
def preset_mode(self) -> str:
"""Return current preset mode."""
return SCHEDULE_MODE_TO_PRESET_MODE_MAP.get(self._schedule_mode, PRESET_RUN)

View File

@@ -373,7 +373,7 @@ class NumberDeviceClass(StrEnum):
SULPHUR_DIOXIDE = "sulphur_dioxide"
"""Amount of SO2.
Unit of measurement: `μg/m³`
Unit of measurement: `ppb` (parts per billion), `μg/m³`
"""
TEMPERATURE = "temperature"
@@ -545,7 +545,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
},
NumberDeviceClass.SOUND_PRESSURE: set(UnitOfSoundPressure),
NumberDeviceClass.SPEED: {*UnitOfSpeed, *UnitOfVolumetricFlux},
NumberDeviceClass.SULPHUR_DIOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.SULPHUR_DIOXIDE: {
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
},
NumberDeviceClass.TEMPERATURE: set(UnitOfTemperature),
NumberDeviceClass.TEMPERATURE_DELTA: set(UnitOfTemperature),
NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import threading
import time
from typing import Any
from nx584 import client as nx584_client
import requests
@@ -28,8 +29,7 @@ CONF_EXCLUDE_ZONES = "exclude_zones"
CONF_ZONE_TYPES = "zone_types"
DEFAULT_HOST = "localhost"
DEFAULT_PORT = "5007"
DEFAULT_SSL = False
DEFAULT_PORT = 5007
ZONE_TYPES_SCHEMA = vol.Schema({cv.positive_int: BINARY_SENSOR_DEVICE_CLASSES_SCHEMA})
@@ -53,10 +53,10 @@ def setup_platform(
) -> None:
"""Set up the NX584 binary sensor platform."""
host = config[CONF_HOST]
port = config[CONF_PORT]
exclude = config[CONF_EXCLUDE_ZONES]
zone_types = config[CONF_ZONE_TYPES]
host: str = config[CONF_HOST]
port: int = config[CONF_PORT]
exclude: list[int] = config[CONF_EXCLUDE_ZONES]
zone_types: dict[int, BinarySensorDeviceClass] = config[CONF_ZONE_TYPES]
try:
client = nx584_client.Client(f"http://{host}:{port}")
@@ -90,15 +90,12 @@ class NX584ZoneSensor(BinarySensorEntity):
_attr_should_poll = False
def __init__(self, zone, zone_type):
def __init__(
self, zone: dict[str, Any], zone_type: BinarySensorDeviceClass
) -> None:
"""Initialize the nx594 binary sensor."""
self._zone = zone
self._zone_type = zone_type
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
self._attr_device_class = zone_type
@property
def name(self):
@@ -112,7 +109,7 @@ class NX584ZoneSensor(BinarySensorEntity):
return self._zone["state"]
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {
"zone_number": self._zone["number"],

View File

@@ -158,7 +158,7 @@ MODEL_NAMES = [ # https://ollama.com/library
"yi",
"zephyr",
]
DEFAULT_MODEL = "qwen3:4b"
DEFAULT_MODEL = "qwen3:4b-instruct"
DEFAULT_CONVERSATION_NAME = "Ollama Conversation"
DEFAULT_AI_TASK_NAME = "Ollama AI Task"

View File

@@ -25,6 +25,7 @@ from homeassistant.core import (
SupportsResponse,
)
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
@@ -96,6 +97,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
response_format="url",
n=1,
)
except openai.AuthenticationError as err:
entry.async_start_reauth(hass)
raise HomeAssistantError("Authentication error") from err
except openai.OpenAIError as err:
raise HomeAssistantError(f"Error generating image: {err}") from err
@@ -179,7 +183,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
try:
response: Response = await client.responses.create(**model_args)
except openai.AuthenticationError as err:
entry.async_start_reauth(hass)
raise HomeAssistantError("Authentication error") from err
except openai.OpenAIError as err:
raise HomeAssistantError(f"Error generating content: {err}") from err
except FileNotFoundError as err:
@@ -245,8 +251,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
try:
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list)
except openai.AuthenticationError as err:
LOGGER.error("Invalid API key: %s", err)
return False
raise ConfigEntryAuthFailed(err) from err
except openai.OpenAIError as err:
raise ConfigEntryNotReady(err) from err
@@ -259,7 +264,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool:
"""Unload OpenAI."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -280,7 +285,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
if not any(entry.version == 1 for entry in entries):
return
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
api_keys_entries: dict[str, tuple[OpenAIConfigEntry, bool]] = {}
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)

View File

@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING
from openai.types.responses.response_output_item import ImageGenerationCall
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -35,7 +34,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: OpenAIConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import json
import logging
from typing import Any
@@ -12,6 +13,7 @@ from voluptuous_openapi import convert
from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
@@ -127,6 +129,10 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_create_entry(
title="ChatGPT",
data=user_input,
@@ -157,6 +163,23 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if not user_input:
return self.async_show_form(
step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA
)
return await self.async_step_user(user_input)
@classmethod
@callback
def async_get_supported_subentry_types(

View File

@@ -89,6 +89,8 @@ UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS: list[str] = [
"gpt-3.5",
"gpt-4-turbo",
"gpt-4o",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-5-mini",
"gpt-5-nano",
]

View File

@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,15 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::openai_conversation::config::step::user::data_description::api_key%]"
},
"description": "Reauthentication required. Please enter your updated API key."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"

View File

@@ -10,7 +10,6 @@ from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.components.sensor import (
DOMAIN as HOMEASSISTANT_DOMAIN,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
@@ -27,7 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo

View File

@@ -55,6 +55,10 @@
}
},
"issues": {
"deprecated_yaml_import_issue_unavailable_host": {
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error while trying to import the YAML configuration.\n\nEnsure your OpenEVSE charger is accessible and restart Home Assistant to try again.",
"title": "The {integration_title} YAML configuration import failed"
},
"yaml_deprecated": {
"description": "Configuring OpenEVSE using YAML is being removed. Your existing YAML configuration has been imported into the UI automatically. Remove the `openevse` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",
"title": "OpenEVSE YAML configuration is deprecated"

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.16.3"]
"requirements": ["opower==0.16.4"]
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from proxmoxer import AuthenticationError, ProxmoxAPI
@@ -10,6 +11,7 @@ import requests.exceptions
from requests.exceptions import ConnectTimeout, SSLError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -18,26 +20,29 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm
from .common import (
ProxmoxClient,
ResourceException,
call_api_container_vm,
parse_api_container_vm,
)
from .const import (
_LOGGER,
CONF_CONTAINERS,
CONF_NODE,
CONF_NODES,
CONF_REALM,
CONF_VMS,
COORDINATORS,
DEFAULT_PORT,
DEFAULT_REALM,
DEFAULT_VERIFY_SSL,
DOMAIN,
PROXMOX_CLIENTS,
TYPE_CONTAINER,
TYPE_VM,
UPDATE_INTERVAL,
@@ -45,6 +50,10 @@ from .const import (
PLATFORMS = [Platform.BINARY_SENSOR]
type ProxmoxConfigEntry = ConfigEntry[
dict[str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]]
]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
@@ -84,109 +93,154 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the platform."""
hass.data.setdefault(DOMAIN, {})
"""Import the Proxmox configuration from YAML."""
if DOMAIN not in config:
return True
def build_client() -> ProxmoxAPI:
"""Build the Proxmox client connection."""
hass.data[PROXMOX_CLIENTS] = {}
for entry in config[DOMAIN]:
host = entry[CONF_HOST]
port = entry[CONF_PORT]
user = entry[CONF_USERNAME]
realm = entry[CONF_REALM]
password = entry[CONF_PASSWORD]
verify_ssl = entry[CONF_VERIFY_SSL]
hass.data[PROXMOX_CLIENTS][host] = None
try:
# Construct an API client with the given data for the given host
proxmox_client = ProxmoxClient(
host, port, user, realm, password, verify_ssl
)
proxmox_client.build_client()
except AuthenticationError:
_LOGGER.warning(
"Invalid credentials for proxmox instance %s:%d", host, port
)
continue
except SSLError:
_LOGGER.error(
(
"Unable to verify proxmox server SSL. "
'Try using "verify_ssl: false" for proxmox instance %s:%d'
),
host,
port,
)
continue
except ConnectTimeout:
_LOGGER.warning("Connection to host %s timed out during setup", host)
continue
except requests.exceptions.ConnectionError:
_LOGGER.warning("Host %s is not reachable", host)
continue
hass.data[PROXMOX_CLIENTS][host] = proxmox_client
await hass.async_add_executor_job(build_client)
coordinators: dict[
str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]
] = {}
hass.data[DOMAIN][COORDINATORS] = coordinators
# Create a coordinator for each vm/container
for host_config in config[DOMAIN]:
host_name = host_config["host"]
coordinators[host_name] = {}
proxmox_client = hass.data[PROXMOX_CLIENTS][host_name]
# Skip invalid hosts
if proxmox_client is None:
continue
proxmox = proxmox_client.get_api_client()
for node_config in host_config["nodes"]:
node_name = node_config["node"]
node_coordinators = coordinators[host_name][node_name] = {}
for vm_id in node_config["vms"]:
coordinator = create_coordinator_container_vm(
hass, proxmox, host_name, node_name, vm_id, TYPE_VM
)
# Fetch initial data
await coordinator.async_refresh()
node_coordinators[vm_id] = coordinator
for container_id in node_config["containers"]:
coordinator = create_coordinator_container_vm(
hass, proxmox, host_name, node_name, container_id, TYPE_CONTAINER
)
# Fetch initial data
await coordinator.async_refresh()
node_coordinators[container_id] = coordinator
for component in PLATFORMS:
await hass.async_create_task(
async_load_platform(hass, component, DOMAIN, {"config": config}, config)
)
hass.async_create_task(_async_setup(hass, config))
return True
def create_coordinator_container_vm(
async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
for entry_config in config[DOMAIN]:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=entry_config,
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Proxmox VE",
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Proxmox VE",
},
)
async def async_setup_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool:
"""Set up a ProxmoxVE instance from a config entry."""
def build_client() -> ProxmoxClient:
"""Build and return the Proxmox client connection."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
user = entry.data[CONF_USERNAME]
realm = entry.data[CONF_REALM]
password = entry.data[CONF_PASSWORD]
verify_ssl = entry.data[CONF_VERIFY_SSL]
try:
client = ProxmoxClient(host, port, user, realm, password, verify_ssl)
client.build_client()
except AuthenticationError as ex:
raise ConfigEntryAuthFailed("Invalid credentials") from ex
except SSLError as ex:
raise ConfigEntryAuthFailed(
f"Unable to verify proxmox server SSL. Try using 'verify_ssl: false' for proxmox instance {host}:{port}"
) from ex
except ConnectTimeout as ex:
raise ConfigEntryNotReady("Connection timed out") from ex
except requests.exceptions.ConnectionError as ex:
raise ConfigEntryNotReady(f"Host {host} is not reachable: {ex}") from ex
else:
return client
proxmox_client = await hass.async_add_executor_job(build_client)
coordinators: dict[
str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]
] = {}
entry.runtime_data = coordinators
host_name = entry.data[CONF_HOST]
coordinators[host_name] = {}
proxmox: ProxmoxAPI = proxmox_client.get_api_client()
for node_config in entry.data[CONF_NODES]:
node_name = node_config[CONF_NODE]
node_coordinators = coordinators[host_name][node_name] = {}
try:
vms, containers = await hass.async_add_executor_job(
_get_vms_containers, proxmox, node_config
)
except (ResourceException, requests.exceptions.ConnectionError) as err:
LOGGER.error("Unable to get vms/containers for node %s: %s", node_name, err)
continue
for vm in vms:
coordinator = _create_coordinator_container_vm(
hass, entry, proxmox, host_name, node_name, vm["vmid"], TYPE_VM
)
await coordinator.async_config_entry_first_refresh()
node_coordinators[vm["vmid"]] = coordinator
for container in containers:
coordinator = _create_coordinator_container_vm(
hass,
entry,
proxmox,
host_name,
node_name,
container["vmid"],
TYPE_CONTAINER,
)
await coordinator.async_config_entry_first_refresh()
node_coordinators[container["vmid"]] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
def _get_vms_containers(
proxmox: ProxmoxAPI,
node_config: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Get vms and containers for a node."""
vms = proxmox.nodes(node_config[CONF_NODE]).qemu.get()
containers = proxmox.nodes(node_config[CONF_NODE]).lxc.get()
assert vms is not None and containers is not None
return vms, containers
def _create_coordinator_container_vm(
hass: HomeAssistant,
entry: ProxmoxConfigEntry,
proxmox: ProxmoxAPI,
host_name: str,
node_name: str,
@@ -205,7 +259,7 @@ def create_coordinator_container_vm(
vm_status = await hass.async_add_executor_job(poll_api)
if vm_status is None:
_LOGGER.warning(
LOGGER.warning(
"Vm/Container %s unable to be found in node %s", vm_id, node_name
)
return None
@@ -214,9 +268,14 @@ def create_coordinator_container_vm(
return DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=None,
LOGGER,
config_entry=entry,
name=f"proxmox_coordinator_{host_name}_{node_name}_{vm_id}",
update_method=async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
async def async_unload_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -2,55 +2,48 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS
from . import ProxmoxConfigEntry
from .const import CONF_CONTAINERS, CONF_NODE, CONF_NODES, CONF_VMS
from .entity import ProxmoxEntity
async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
entry: ProxmoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors."""
if discovery_info is None:
return
sensors = []
for host_config in discovery_info["config"][DOMAIN]:
host_name = host_config["host"]
host_name_coordinators = hass.data[DOMAIN][COORDINATORS][host_name]
host_name = entry.data[CONF_HOST]
host_name_coordinators = entry.runtime_data[host_name]
if hass.data[PROXMOX_CLIENTS][host_name] is None:
continue
for node_config in entry.data[CONF_NODES]:
node_name = node_config[CONF_NODE]
for node_config in host_config["nodes"]:
node_name = node_config["node"]
for dev_id in node_config[CONF_VMS] + node_config[CONF_CONTAINERS]:
coordinator = host_name_coordinators[node_name][dev_id]
for dev_id in node_config["vms"] + node_config["containers"]:
coordinator = host_name_coordinators[node_name][dev_id]
if TYPE_CHECKING:
assert coordinator.data is not None
name = coordinator.data["name"]
sensor = create_binary_sensor(
coordinator, host_name, node_name, dev_id, name
)
sensors.append(sensor)
# unfound case
if (coordinator_data := coordinator.data) is None:
continue
name = coordinator_data["name"]
sensor = create_binary_sensor(
coordinator, host_name, node_name, dev_id, name
)
sensors.append(sensor)
add_entities(sensors)
async_add_entities(sensors)
def create_binary_sensor(

View File

@@ -0,0 +1,175 @@
"""Config flow for Proxmox VE integration."""
from __future__ import annotations
import logging
from typing import Any
from proxmoxer import AuthenticationError, ProxmoxAPI
import requests
from requests.exceptions import ConnectTimeout, SSLError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .common import ResourceException
from .const import (
CONF_CONTAINERS,
CONF_NODE,
CONF_NODES,
CONF_REALM,
CONF_VMS,
DEFAULT_PORT,
DEFAULT_REALM,
DEFAULT_VERIFY_SSL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_REALM, default=DEFAULT_REALM): cv.string,
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
}
)
def _sanitize_userid(data: dict[str, Any]) -> str:
"""Sanitize the user ID."""
return (
data[CONF_USERNAME]
if "@" in data[CONF_USERNAME]
else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}"
)
def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Validate the user input and fetch data (sync, for executor)."""
try:
client = ProxmoxAPI(
data[CONF_HOST],
port=data[CONF_PORT],
user=_sanitize_userid(data),
password=data[CONF_PASSWORD],
verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)
nodes = client.nodes.get()
except AuthenticationError as err:
raise ProxmoxAuthenticationError from err
except SSLError as err:
raise ProxmoxSSLError from err
except ConnectTimeout as err:
raise ProxmoxConnectTimeout from err
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise ProxmoxNoNodesFound from err
_LOGGER.debug("Proxmox nodes: %s", nodes)
nodes_data: list[dict[str, Any]] = []
for node in nodes:
try:
vms = client.nodes(node["node"]).qemu.get()
containers = client.nodes(node["node"]).lxc.get()
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise ProxmoxNoNodesFound from err
nodes_data.append(
{
CONF_NODE: node["node"],
CONF_VMS: [vm["vmid"] for vm in vms],
CONF_CONTAINERS: [container["vmid"] for container in containers],
}
)
_LOGGER.debug("Nodes with data: %s", nodes_data)
return nodes_data
class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Proxmox VE."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
proxmox_nodes: list[dict[str, Any]] = []
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try:
proxmox_nodes = await self.hass.async_add_executor_job(
_get_nodes_data, user_input
)
except ProxmoxConnectTimeout:
errors["base"] = "connect_timeout"
except ProxmoxAuthenticationError:
errors["base"] = "invalid_auth"
except ProxmoxSSLError:
errors["base"] = "ssl_error"
except ProxmoxNoNodesFound:
errors["base"] = "no_nodes_found"
if not errors:
return self.async_create_entry(
title=user_input[CONF_HOST],
data={**user_input, CONF_NODES: proxmox_nodes},
)
return self.async_show_form(
step_id="user",
data_schema=CONFIG_SCHEMA,
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle a flow initiated by configuration file."""
self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]})
try:
proxmox_nodes = await self.hass.async_add_executor_job(
_get_nodes_data, import_data
)
except ProxmoxConnectTimeout:
return self.async_abort(reason="connect_timeout")
except ProxmoxAuthenticationError:
return self.async_abort(reason="invalid_auth")
except ProxmoxSSLError:
return self.async_abort(reason="ssl_error")
except ProxmoxNoNodesFound:
return self.async_abort(reason="no_nodes_found")
return self.async_create_entry(
title=import_data[CONF_HOST],
data={**import_data, CONF_NODES: proxmox_nodes},
)
class ProxmoxNoNodesFound(HomeAssistantError):
"""Error to indicate no nodes found."""
class ProxmoxConnectTimeout(HomeAssistantError):
"""Error to indicate a connection timeout."""
class ProxmoxSSLError(HomeAssistantError):
"""Error to indicate an SSL error."""
class ProxmoxAuthenticationError(HomeAssistantError):
"""Error to indicate an authentication error."""

View File

@@ -1,16 +1,12 @@
"""Constants for ProxmoxVE."""
import logging
DOMAIN = "proxmoxve"
PROXMOX_CLIENTS = "proxmox_clients"
CONF_REALM = "realm"
CONF_NODE = "node"
CONF_NODES = "nodes"
CONF_VMS = "vms"
CONF_CONTAINERS = "containers"
COORDINATORS = "coordinators"
DEFAULT_PORT = 8006
DEFAULT_REALM = "pam"
@@ -18,5 +14,3 @@ DEFAULT_VERIFY_SSL = True
TYPE_VM = 0
TYPE_CONTAINER = 1
UPDATE_INTERVAL = 60
_LOGGER = logging.getLogger(__package__)

View File

@@ -1,8 +1,10 @@
{
"domain": "proxmoxve",
"name": "Proxmox VE",
"codeowners": ["@jhollowe", "@Corbeno"],
"codeowners": ["@jhollowe", "@Corbeno", "@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/proxmoxve",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["proxmoxer"],
"quality_scale": "legacy",

View File

@@ -0,0 +1,46 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "Cannot connect to Proxmox VE server",
"connect_timeout": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_nodes_found": "No active nodes found",
"ssl_error": "SSL check failed. Check the SSL settings"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"realm": "Realm",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Enter your Proxmox VE server details to set up the integration.",
"title": "Connect to Proxmox VE"
}
}
},
"issues": {
"deprecated_yaml_import_issue_connect_timeout": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"deprecated_yaml_import_issue_invalid_auth": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
},
"deprecated_yaml_import_issue_no_nodes_found": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, no active nodes were found on the Proxmox VE server. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
},
"deprecated_yaml_import_issue_ssl_error": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an SSL error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
}
}
}

View File

@@ -24,9 +24,9 @@ from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
from .const import DATA_QUIKSWITCH, DOMAIN
DOMAIN = "qwikswitch"
_LOGGER = logging.getLogger(__name__)
CONF_DIMMER_ADJUST = "dimmer_adjust"
CONF_BUTTON_EVENTS = "button_events"
@@ -96,7 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if not await qsusb.update_from_devices():
return False
hass.data[DOMAIN] = qsusb
hass.data[DATA_QUIKSWITCH] = qsusb
comps: dict[Platform, list] = {
Platform.SWITCH: [],
@@ -168,7 +168,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback
def async_stop(_):
"""Stop the listener."""
hass.data[DOMAIN].stop()
hass.data[DATA_QUIKSWITCH].stop()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_stop)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from pyqwikswitch.qwikswitch import SENSORS
@@ -14,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
from .const import DATA_QUIKSWITCH, DOMAIN
from .entity import QSEntity
_LOGGER = logging.getLogger(__name__)
@@ -30,7 +31,7 @@ async def async_setup_platform(
if discovery_info is None:
return
qsusb = hass.data[DOMAIN]
qsusb = hass.data[DATA_QUIKSWITCH]
_LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s", qsusb, discovery_info)
devs = [QSBinarySensor(sensor) for sensor in discovery_info[DOMAIN]]
add_entities(devs)
@@ -39,9 +40,7 @@ async def async_setup_platform(
class QSBinarySensor(QSEntity, BinarySensorEntity):
"""Sensor based on a Qwikswitch relay/dimmer module."""
_val = False
def __init__(self, sensor):
def __init__(self, sensor: dict[str, Any]) -> None:
"""Initialize the sensor."""
super().__init__(sensor["id"], sensor["name"])
@@ -50,7 +49,9 @@ class QSBinarySensor(QSEntity, BinarySensorEntity):
self._decode, _ = SENSORS[sensor_type]
self._invert = not sensor.get("invert", False)
self._class = sensor.get("class", "door")
self._attr_is_on = not self._invert
self._attr_device_class = sensor.get("class", BinarySensorDeviceClass.DOOR)
self._attr_unique_id = f"qs{self.qsid}:{self.channel}"
@callback
def update_packet(self, packet):
@@ -65,20 +66,5 @@ class QSBinarySensor(QSEntity, BinarySensorEntity):
packet,
)
if val is not None:
self._val = bool(val)
self._attr_is_on = bool(val) == self._invert
self.async_write_ha_state()
@property
def is_on(self):
"""Check if device is on (non-zero)."""
return self._val == self._invert
@property
def unique_id(self):
"""Return a unique identifier for this sensor."""
return f"qs{self.qsid}:{self.channel}"
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor."""
return self._class

View File

@@ -0,0 +1,13 @@
"""Support for Qwikswitch devices."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from pyqwikswitch.async_ import QSUsb
DOMAIN = "qwikswitch"
DATA_QUIKSWITCH: HassKey[QSUsb] = HassKey(DOMAIN)

View File

@@ -7,7 +7,7 @@ from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from . import DOMAIN
from .const import DATA_QUIKSWITCH
class QSEntity(Entity):
@@ -15,21 +15,12 @@ class QSEntity(Entity):
_attr_should_poll = False
def __init__(self, qsid, name):
def __init__(self, qsid: str, name: str) -> None:
"""Initialize the QSEntity."""
self._name = name
self._attr_name = name
self._attr_unique_id = f"qs{qsid}"
self.qsid = qsid
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unique_id(self):
"""Return a unique identifier for this sensor."""
return f"qs{self.qsid}"
@callback
def update_packet(self, packet):
"""Receive update packet from QSUSB. Match dispather_send signature."""
@@ -67,8 +58,8 @@ class QSToggleEntity(QSEntity):
async def async_turn_on(self, **kwargs):
"""Turn the device on."""
new = kwargs.get(ATTR_BRIGHTNESS, 255)
self.hass.data[DOMAIN].devices.set_value(self.qsid, new)
self.hass.data[DATA_QUIKSWITCH].devices.set_value(self.qsid, new)
async def async_turn_off(self, **_):
"""Turn the device off."""
self.hass.data[DOMAIN].devices.set_value(self.qsid, 0)
self.hass.data[DATA_QUIKSWITCH].devices.set_value(self.qsid, 0)

View File

@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
from .const import DATA_QUIKSWITCH, DOMAIN
from .entity import QSEntity
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ async def async_setup_platform(
if discovery_info is None:
return
qsusb = hass.data[DOMAIN]
qsusb = hass.data[DATA_QUIKSWITCH]
_LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info)
devs = [QSSensor(sensor) for sensor in discovery_info[DOMAIN]]
add_entities(devs)
@@ -37,21 +37,24 @@ async def async_setup_platform(
class QSSensor(QSEntity, SensorEntity):
"""Sensor based on a Qwikswitch relay/dimmer module."""
_val: Any | None = None
def __init__(self, sensor):
def __init__(self, sensor: dict[str, Any]) -> None:
"""Initialize the sensor."""
super().__init__(sensor["id"], sensor["name"])
self.channel = sensor["channel"]
sensor_type = sensor["type"]
self._decode, self.unit = SENSORS[sensor_type]
self._attr_unique_id = f"qs{self.qsid}:{self.channel}"
decode, unit = SENSORS[sensor_type]
# this cannot happen because it only happens in bool and this should be redirected to binary_sensor
assert not isinstance(self.unit, type), (
assert not isinstance(unit, type), (
f"boolean sensor id={sensor['id']} name={sensor['name']}"
)
self._decode = decode
self._attr_native_unit_of_measurement = unit
@callback
def update_packet(self, packet):
"""Receive update packet from QSUSB."""
@@ -65,20 +68,5 @@ class QSSensor(QSEntity, SensorEntity):
packet,
)
if val is not None:
self._val = val
self._attr_native_value = str(val)
self.async_write_ha_state()
@property
def native_value(self):
"""Return the value of the sensor."""
return None if self._val is None else str(self._val)
@property
def unique_id(self):
"""Return a unique identifier for this sensor."""
return f"qs{self.qsid}:{self.channel}"
@property
def native_unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return self.unit

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
from .const import DATA_QUIKSWITCH, DOMAIN
from .entity import QSToggleEntity
@@ -21,7 +21,7 @@ async def async_setup_platform(
if discovery_info is None:
return
qsusb = hass.data[DOMAIN]
qsusb = hass.data[DATA_QUIKSWITCH]
devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[DOMAIN]]
add_entities(devs)

View File

@@ -64,6 +64,7 @@ from homeassistant.util.unit_conversion import (
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -225,6 +226,7 @@ _PRIMARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
_SECONDARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
CarbonMonoxideConcentrationConverter,
TemperatureDeltaConverter,
SulphurDioxideConcentrationConverter,
]
STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {

View File

@@ -38,6 +38,7 @@ from homeassistant.util.unit_conversion import (
ReactiveEnergyConverter,
ReactivePowerConverter,
SpeedConverter,
SulphurDioxideConcentrationConverter,
TemperatureConverter,
TemperatureDeltaConverter,
UnitlessRatioConverter,
@@ -94,6 +95,9 @@ UNIT_SCHEMA = vol.Schema(
vol.Optional("reactive_energy"): vol.In(ReactiveEnergyConverter.VALID_UNITS),
vol.Optional("reactive_power"): vol.In(ReactivePowerConverter.VALID_UNITS),
vol.Optional("speed"): vol.In(SpeedConverter.VALID_UNITS),
vol.Optional("sulphur_dioxide"): vol.In(
SulphurDioxideConcentrationConverter.VALID_UNITS
),
vol.Optional("temperature"): vol.In(TemperatureConverter.VALID_UNITS),
vol.Optional("temperature_delta"): vol.In(
TemperatureDeltaConverter.VALID_UNITS

View File

@@ -44,7 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator = entry.runtime_data
coordinator.client.close()
await entry.runtime_data.client.async_close()
return unload_ok

View File

@@ -17,13 +17,22 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LeilSaunaConfigEntry
from .const import DELAYED_REFRESH_SECONDS, DOMAIN
from . import LeilSaunaConfigEntry, LeilSaunaCoordinator
from .const import (
DEFAULT_PRESET_NAME_TYPE_1,
DEFAULT_PRESET_NAME_TYPE_2,
DEFAULT_PRESET_NAME_TYPE_3,
DELAYED_REFRESH_SECONDS,
DOMAIN,
OPT_PRESET_NAME_TYPE_1,
OPT_PRESET_NAME_TYPE_2,
OPT_PRESET_NAME_TYPE_3,
)
from .entity import LeilSaunaEntity
PARALLEL_UPDATES = 1
@@ -52,14 +61,51 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
"""Representation of a Saunum Leil Sauna climate entity."""
_attr_name = None
_attr_translation_key = "saunum_climate"
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.PRESET_MODE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_precision = PRECISION_WHOLE
_attr_target_temperature_step = 1.0
_attr_min_temp = MIN_TEMPERATURE
_attr_max_temp = MAX_TEMPERATURE
_attr_fan_modes = [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
_preset_name_map: dict[int, str]
def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator)
self._update_preset_names()
def _update_preset_names(self) -> None:
"""Update preset names from config entry options."""
options = self.coordinator.config_entry.options
self._preset_name_map = {
0: options.get(OPT_PRESET_NAME_TYPE_1, DEFAULT_PRESET_NAME_TYPE_1),
1: options.get(OPT_PRESET_NAME_TYPE_2, DEFAULT_PRESET_NAME_TYPE_2),
2: options.get(OPT_PRESET_NAME_TYPE_3, DEFAULT_PRESET_NAME_TYPE_3),
}
self._attr_preset_modes = list(self._preset_name_map.values())
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.config_entry.add_update_listener(
self._async_update_listener
)
)
async def _async_update_listener(
self, hass: HomeAssistant, entry: LeilSaunaConfigEntry
) -> None:
"""Handle options update."""
self._update_preset_names()
self.async_write_ha_state()
@property
def current_temperature(self) -> float | None:
@@ -98,6 +144,14 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
else HVACAction.IDLE
)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
sauna_type = self.coordinator.data.sauna_type
if sauna_type is not None and sauna_type in self._preset_name_map:
return self._preset_name_map[sauna_type]
return self._preset_name_map[0]
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
if hvac_mode == HVACMode.HEAT and self.coordinator.data.door_open:
@@ -143,10 +197,44 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
"""Set new fan mode."""
if not self.coordinator.data.session_active:
raise ServiceValidationError(
"Cannot change fan mode when sauna session is not active",
translation_domain=DOMAIN,
translation_key="session_not_active",
)
await self.coordinator.client.async_set_fan_speed(FAN_MODE_TO_SPEED[fan_mode])
try:
await self.coordinator.client.async_set_fan_speed(
FAN_MODE_TO_SPEED[fan_mode]
)
except SaunumException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_fan_mode_failed",
) from err
await self.coordinator.async_request_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode (sauna type)."""
if self.coordinator.data.session_active:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="preset_session_active",
)
# Find the sauna type value from the preset name
sauna_type_value = 0 # Default to type 1
for type_value, type_name in self._preset_name_map.items():
if type_name == preset_mode:
sauna_type_value = type_value
break
try:
await self.coordinator.client.async_set_sauna_type(sauna_type_value)
except SaunumException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_preset_failed",
translation_placeholders={"preset_mode": preset_mode},
) from err
await self.coordinator.async_request_refresh()

View File

@@ -8,11 +8,26 @@ from typing import Any
from pysaunum import SaunumClient, SaunumException
import voluptuous as vol
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from . import LeilSaunaConfigEntry
from .const import (
DEFAULT_PRESET_NAME_TYPE_1,
DEFAULT_PRESET_NAME_TYPE_2,
DEFAULT_PRESET_NAME_TYPE_3,
DOMAIN,
OPT_PRESET_NAME_TYPE_1,
OPT_PRESET_NAME_TYPE_2,
OPT_PRESET_NAME_TYPE_3,
)
_LOGGER = logging.getLogger(__name__)
@@ -36,7 +51,7 @@ async def validate_input(data: dict[str, Any]) -> None:
# Try to read data to verify communication
await client.async_get_data()
finally:
client.close()
await client.async_close()
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -45,6 +60,14 @@ class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
@staticmethod
@callback
def async_get_options_flow(
config_entry: LeilSaunaConfigEntry,
) -> LeilSaunaOptionsFlow:
"""Get the options flow for this handler."""
return LeilSaunaOptionsFlow()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -82,3 +105,40 @@ class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
class LeilSaunaOptionsFlow(OptionsFlow):
"""Handle options flow for Saunum Leil Sauna Control Unit."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options for preset mode names."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
OPT_PRESET_NAME_TYPE_1,
default=self.config_entry.options.get(
OPT_PRESET_NAME_TYPE_1, DEFAULT_PRESET_NAME_TYPE_1
),
): cv.string,
vol.Optional(
OPT_PRESET_NAME_TYPE_2,
default=self.config_entry.options.get(
OPT_PRESET_NAME_TYPE_2, DEFAULT_PRESET_NAME_TYPE_2
),
): cv.string,
vol.Optional(
OPT_PRESET_NAME_TYPE_3,
default=self.config_entry.options.get(
OPT_PRESET_NAME_TYPE_3, DEFAULT_PRESET_NAME_TYPE_3
),
): cv.string,
}
),
)

View File

@@ -7,3 +7,13 @@ DOMAIN: Final = "saunum"
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)
# Option keys for preset names
OPT_PRESET_NAME_TYPE_1: Final = "preset_name_type_1"
OPT_PRESET_NAME_TYPE_2: Final = "preset_name_type_2"
OPT_PRESET_NAME_TYPE_3: Final = "preset_name_type_3"
# Default preset names (translation keys)
DEFAULT_PRESET_NAME_TYPE_1: Final = "type_1"
DEFAULT_PRESET_NAME_TYPE_2: Final = "type_2"
DEFAULT_PRESET_NAME_TYPE_3: Final = "type_3"

View File

@@ -23,6 +23,7 @@ async def async_get_config_entry_diagnostics(
# Build diagnostics data
diagnostics_data: dict[str, Any] = {
"config": async_redact_data(entry.data, REDACT_CONFIG),
"options": dict(entry.options),
"client_info": {"connected": coordinator.client.is_connected},
"coordinator_info": {
"last_update_success": coordinator.last_update_success,

Some files were not shown because too many files have changed in this diff Show More