Compare commits

...

71 Commits

Author SHA1 Message Date
farmio
e43cac2703 genitive 2026-01-20 15:57:32 +01:00
farmio
0b488bb7b0 typo in comment 2026-01-20 09:24:47 +01:00
farmio
64946aa379 remove unused code 2026-01-20 09:22:50 +01:00
farmio
ecbb034124 update snapshots and typos 2026-01-20 09:20:28 +01:00
farmio
25591df23a Support KNX number entity configuration from UI 2026-01-20 09:02:07 +01: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
epenet
7c6a31861e Improve type hints in egardia (#161048) 2026-01-16 10:08:24 +01:00
Robert Resch
b2b25ca28c Revert "Add SmartThings media-player audio notifications" (#161049) 2026-01-16 10:06:30 +01:00
epenet
ad9efab16a Improve type hints in concord232 (#161045) 2026-01-16 09:46:53 +01:00
Matthias Alphart
e967d33911 Update knx-frontend to 2026.1.15.112308 (#161004) 2026-01-16 09:37:09 +01:00
epenet
86bacdbdde Use shorthand attributes in oasa_telematics (#160990) 2026-01-16 09:34:51 +01:00
Robert Resch
644a40674d Make shebang matcher stricter (#160986) 2026-01-16 09:21:19 +01:00
246 changed files with 19005 additions and 14909 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

@@ -247,17 +247,11 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- name: Register yamllint problem matcher
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
- name: Register check-json problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-json.json"
- name: Register check executables problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
- name: Register codespell problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
@@ -316,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 >-
@@ -380,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
@@ -431,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

@@ -4,7 +4,7 @@
"owner": "check-executables-have-shebangs",
"pattern": [
{
"regexp": "^(.+):\\s(.+)$",
"regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$",
"file": 1,
"message": 2
}

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

@@ -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

@@ -49,11 +49,11 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Concord232 alarm control panel platform."""
name = config[CONF_NAME]
code = config.get(CONF_CODE)
mode = config[CONF_MODE]
host = config[CONF_HOST]
port = config[CONF_PORT]
name: str = config[CONF_NAME]
code: str | None = config.get(CONF_CODE)
mode: str = config[CONF_MODE]
host: str = config[CONF_HOST]
port: int = config[CONF_PORT]
url = f"http://{host}:{port}"
@@ -72,7 +72,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
def __init__(self, url, name, code, mode):
def __init__(self, url: str, name: str, code: str | None, mode: str) -> None:
"""Initialize the Concord232 alarm panel."""
self._attr_name = name
@@ -125,7 +125,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
return
self._alarm.arm("away")
def _validate_code(self, code, state):
def _validate_code(self, code: str | None, state: AlarmControlPanelState) -> bool:
"""Validate given code."""
if self._code is None:
return True

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime
import logging
from typing import Any
from concord232 import client as concord232_client
import requests
@@ -29,8 +30,7 @@ CONF_ZONE_TYPES = "zone_types"
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "Alarm"
DEFAULT_PORT = "5007"
DEFAULT_SSL = False
DEFAULT_PORT = 5007
SCAN_INTERVAL = datetime.timedelta(seconds=10)
@@ -56,10 +56,10 @@ def setup_platform(
) -> None:
"""Set up the Concord232 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]
sensors = []
try:
@@ -84,7 +84,6 @@ def setup_platform(
if zone["number"] not in exclude:
sensors.append(
Concord232ZoneSensor(
hass,
client,
zone,
zone_types.get(zone["number"], get_opening_type(zone)),
@@ -110,26 +109,25 @@ def get_opening_type(zone):
class Concord232ZoneSensor(BinarySensorEntity):
"""Representation of a Concord232 zone as a sensor."""
def __init__(self, hass, client, zone, zone_type):
def __init__(
self,
client: concord232_client.Client,
zone: dict[str, Any],
zone_type: BinarySensorDeviceClass,
) -> None:
"""Initialize the Concord232 binary sensor."""
self._hass = hass
self._client = client
self._zone = zone
self._number = zone["number"]
self._zone_type = zone_type
self._attr_device_class = zone_type
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
@property
def name(self):
def name(self) -> str:
"""Return the name of the binary sensor."""
return self._zone["name"]
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
# True means "faulted" or "open" or "abnormal state"
return bool(self._zone["state"] != "Normal")
@@ -145,5 +143,5 @@ class Concord232ZoneSensor(BinarySensorEntity):
if hasattr(self._client, "zones"):
self._zone = next(
(x for x in self._client.zones if x["number"] == self._number), None
x for x in self._client.zones if x["number"] == self._number
)

View File

@@ -18,6 +18,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
@@ -35,7 +36,7 @@ DEFAULT_REPORT_SERVER_PORT = 52010
DEFAULT_VERSION = "GATE-01"
DOMAIN = "egardia"
EGARDIA_DEVICE = "egardiadevice"
EGARDIA_DEVICE: HassKey[egardiadevice.EgardiaDevice] = HassKey(DOMAIN)
EGARDIA_NAME = "egardianame"
EGARDIA_REPORT_SERVER_CODES = "egardia_rs_codes"
EGARDIA_REPORT_SERVER_ENABLED = "egardia_rs_enabled"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from pythonegardia.egardiadevice import EgardiaDevice
import requests
from homeassistant.components.alarm_control_panel import (
@@ -11,6 +12,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -47,10 +49,10 @@ def setup_platform(
if discovery_info is None:
return
device = EgardiaAlarm(
discovery_info["name"],
discovery_info[CONF_NAME],
hass.data[EGARDIA_DEVICE],
discovery_info[CONF_REPORT_SERVER_ENABLED],
discovery_info.get(CONF_REPORT_SERVER_CODES),
discovery_info[CONF_REPORT_SERVER_CODES],
discovery_info[CONF_REPORT_SERVER_PORT],
)
@@ -67,8 +69,13 @@ class EgardiaAlarm(AlarmControlPanelEntity):
)
def __init__(
self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010
):
self,
name: str,
egardiasystem: EgardiaDevice,
rs_enabled: bool,
rs_codes: dict[str, list[str]],
rs_port: int,
) -> None:
"""Initialize the Egardia alarm."""
self._attr_name = name
self._egardiasystem = egardiasystem
@@ -85,9 +92,7 @@ class EgardiaAlarm(AlarmControlPanelEntity):
@property
def should_poll(self) -> bool:
"""Poll if no report server is enabled."""
if not self._rs_enabled:
return True
return False
return not self._rs_enabled
def handle_status_event(self, event):
"""Handle the Egardia system status event."""

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from pythonegardia.egardiadevice import EgardiaDevice
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -51,30 +52,20 @@ async def async_setup_platform(
class EgardiaBinarySensor(BinarySensorEntity):
"""Represents a sensor based on an Egardia sensor (IR, Door Contact)."""
def __init__(self, sensor_id, name, egardia_system, device_class):
def __init__(
self,
sensor_id: str,
name: str,
egardia_system: EgardiaDevice,
device_class: BinarySensorDeviceClass | None,
) -> None:
"""Initialize the sensor device."""
self._id = sensor_id
self._name = name
self._state = None
self._device_class = device_class
self._attr_name = name
self._attr_device_class = device_class
self._egardia_system = egardia_system
def update(self) -> None:
"""Update the status."""
egardia_input = self._egardia_system.getsensorstate(self._id)
self._state = STATE_ON if egardia_input else STATE_OFF
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def is_on(self):
"""Whether the device is switched on."""
return self._state == STATE_ON
@property
def device_class(self) -> BinarySensorDeviceClass | None:
"""Return the device class."""
return self._device_class
self._attr_is_on = bool(egardia_input)

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

@@ -168,6 +168,7 @@ SUPPORTED_PLATFORMS_UI: Final = {
Platform.FAN,
Platform.DATETIME,
Platform.LIGHT,
Platform.NUMBER,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
@@ -231,6 +232,14 @@ class FanConf:
MAX_STEP: Final = "max_step"
class NumberConf:
"""Common config keys for number."""
MAX: Final = "max"
MIN: Final = "min"
STEP: Final = "step"
class SceneConf:
"""Common config keys for scene."""

View File

@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.14.0",
"xknxproject==3.8.2",
"knx-frontend==2025.12.30.151231"
"knx-frontend==2026.1.15.112308"
],
"single_config_entry": true
}

View File

@@ -4,28 +4,43 @@ from __future__ import annotations
from typing import cast
from xknx import XKNX
from xknx.devices import NumericValue
from homeassistant import config_entries
from homeassistant.components.number import RestoreNumber
from homeassistant.components.number import NumberDeviceClass, NumberMode, RestoreNumber
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_ENTITY_CATEGORY,
CONF_MODE,
CONF_NAME,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.enum import try_parse_enum
from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
CONF_SYNC_STATE,
DOMAIN,
KNX_ADDRESS,
KNX_MODULE_KEY,
NumberConf,
)
from .dpt import get_supported_dpts
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .knx_module import KNXModule
from .schema import NumberSchema
from .storage.const import CONF_ENTITY, CONF_GA_SENSOR
from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -35,52 +50,36 @@ async def async_setup_entry(
) -> None:
"""Set up number(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
config: list[ConfigType] = knx_module.config_yaml[Platform.NUMBER]
async_add_entities(KNXNumber(knx_module, entity_config) for entity_config in config)
def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue:
"""Return a KNX NumericValue to be used within XKNX."""
return NumericValue(
xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.NUMBER,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiNumber,
),
)
entities: list[KnxYamlEntity | KnxUiEntity] = []
if yaml_platform_config := knx_module.config_yaml.get(Platform.NUMBER):
entities.extend(
KnxYamlNumber(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.NUMBER):
entities.extend(
KnxUiNumber(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
class KNXNumber(KnxYamlEntity, RestoreNumber):
class _KnxNumber(RestoreNumber):
"""Representation of a KNX number."""
_device: NumericValue
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX number."""
super().__init__(
knx_module=knx_module,
device=_create_numeric_value(knx_module.xknx, config),
)
self._attr_native_max_value = config.get(
NumberSchema.CONF_MAX,
self._device.sensor_value.dpt_class.value_max,
)
self._attr_native_min_value = config.get(
NumberSchema.CONF_MIN,
self._device.sensor_value.dpt_class.value_min,
)
self._attr_mode = config[CONF_MODE]
self._attr_native_step = config.get(
NumberSchema.CONF_STEP,
self._device.sensor_value.dpt_class.resolution,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._device.sensor_value.value = max(0, self._attr_native_min_value)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
@@ -101,3 +100,102 @@ class KNXNumber(KnxYamlEntity, RestoreNumber):
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self._device.set(value)
class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
"""Representation of a KNX number configured from YAML."""
_device: NumericValue
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX number."""
super().__init__(
knx_module=knx_module,
device=NumericValue(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
),
)
self._attr_native_max_value = config.get(
NumberConf.MAX,
self._device.sensor_value.dpt_class.value_max,
)
self._attr_native_min_value = config.get(
NumberConf.MIN,
self._device.sensor_value.dpt_class.value_min,
)
self._attr_mode = config[CONF_MODE]
self._attr_native_step = config.get(
NumberConf.STEP,
self._device.sensor_value.dpt_class.resolution,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._device.sensor_value.value = max(0, self._attr_native_min_value)
class KnxUiNumber(_KnxNumber, KnxUiEntity):
"""Representation of a KNX number configured from UI."""
_device: NumericValue
def __init__(
self,
knx_module: KNXModule,
unique_id: str,
config: ConfigType,
) -> None:
"""Initialize a KNX number."""
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR)
assert dpt_string is not None # required for number
dpt_info = get_supported_dpts()[dpt_string]
self._device = NumericValue(
knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address=knx_conf.get_write(CONF_GA_SENSOR),
group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR),
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
sync_state=knx_conf.get(CONF_SYNC_STATE),
value_type=dpt_string,
)
if device_class_override := knx_conf.get(CONF_DEVICE_CLASS):
self._attr_device_class = try_parse_enum(
NumberDeviceClass, device_class_override
)
else:
self._attr_device_class = try_parse_enum(
# sensor device classes should, with some exceptions ("enum" etc.), align with number device classes
NumberDeviceClass,
dpt_info["sensor_device_class"],
)
self._attr_mode = NumberMode(knx_conf.get(CONF_MODE))
self._attr_native_max_value = knx_conf.get(
NumberConf.MAX,
default=self._device.sensor_value.dpt_class.value_max,
)
self._attr_native_min_value = knx_conf.get(
NumberConf.MIN,
default=self._device.sensor_value.dpt_class.value_min,
)
self._attr_native_step = knx_conf.get(
NumberConf.STEP,
default=self._device.sensor_value.dpt_class.resolution,
)
self._attr_native_unit_of_measurement = (
knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"]
)
self._device.sensor_value.value = max(0, self._attr_native_min_value)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from abc import ABC
from collections import OrderedDict
import math
from typing import ClassVar, Final
import voluptuous as vol
@@ -61,6 +60,7 @@ from .const import (
CoverConf,
FanConf,
FanZeroMode,
NumberConf,
SceneConf,
)
from .validation import (
@@ -72,47 +72,18 @@ from .validation import (
sensor_type_validator,
string_type_validator,
sync_state_validator,
validate_number_attributes,
)
##################
# KNX SUB VALIDATORS
##################
def number_limit_sub_validator(entity_config: OrderedDict) -> OrderedDict:
"""Validate a number entity configurations dependent on configured value type."""
value_type = entity_config[CONF_TYPE]
min_config: float | None = entity_config.get(NumberSchema.CONF_MIN)
max_config: float | None = entity_config.get(NumberSchema.CONF_MAX)
step_config: float | None = entity_config.get(NumberSchema.CONF_STEP)
dpt_class = DPTNumeric.parse_transcoder(value_type)
if dpt_class is None:
raise vol.Invalid(f"'type: {value_type}' is not a valid numeric sensor type.")
# Infinity is not supported by Home Assistant frontend so user defined
# config is required if if xknx DPTNumeric subclass defines it as limit.
if min_config is None and dpt_class.value_min == -math.inf:
raise vol.Invalid(f"'min' key required for value type '{value_type}'")
if min_config is not None and min_config < dpt_class.value_min:
raise vol.Invalid(
f"'min: {min_config}' undercuts possible minimum"
f" of value type '{value_type}': {dpt_class.value_min}"
)
if max_config is None and dpt_class.value_max == math.inf:
raise vol.Invalid(f"'max' key required for value type '{value_type}'")
if max_config is not None and max_config > dpt_class.value_max:
raise vol.Invalid(
f"'max: {max_config}' exceeds possible maximum"
f" of value type '{value_type}': {dpt_class.value_max}"
)
if step_config is not None and step_config < dpt_class.resolution:
raise vol.Invalid(
f"'step: {step_config}' undercuts possible minimum step"
f" of value type '{value_type}': {dpt_class.resolution}"
)
return entity_config
def _number_limit_sub_validator(config: dict) -> dict:
"""Validate min, max, and step values for a number entity."""
transcoder = DPTNumeric.parse_transcoder(config[CONF_TYPE])
assert transcoder is not None # already checked by numeric_type_validator
return validate_number_attributes(transcoder, config)
def _max_payload_value(payload_length: int) -> int:
@@ -784,10 +755,6 @@ class NumberSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX numbers."""
PLATFORM = Platform.NUMBER
CONF_MAX = "max"
CONF_MIN = "min"
CONF_STEP = "step"
DEFAULT_NAME = "KNX Number"
ENTITY_SCHEMA = vol.All(
@@ -801,13 +768,13 @@ class NumberSchema(KNXPlatformSchema):
vol.Required(CONF_TYPE): numeric_type_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_MAX): vol.Coerce(float),
vol.Optional(CONF_MIN): vol.Coerce(float),
vol.Optional(CONF_STEP): cv.positive_float,
vol.Optional(NumberConf.MAX): vol.Coerce(float),
vol.Optional(NumberConf.MIN): vol.Coerce(float),
vol.Optional(NumberConf.STEP): cv.positive_float,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
number_limit_sub_validator,
_number_limit_sub_validator,
)

View File

@@ -3,12 +3,18 @@
from enum import StrEnum, unique
import voluptuous as vol
from xknx.dpt import DPTNumeric
from homeassistant.components.climate import HVACMode
from homeassistant.components.number import (
DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS,
NumberDeviceClass,
NumberMode,
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
DEVICE_CLASS_STATE_CLASSES,
DEVICE_CLASS_UNITS,
DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS,
STATE_CLASS_UNITS,
SensorDeviceClass,
SensorStateClass,
@@ -42,9 +48,11 @@ from ..const import (
CoverConf,
FanConf,
FanZeroMode,
NumberConf,
SceneConf,
)
from ..dpt import get_supported_dpts
from ..validation import validate_number_attributes
from .const import (
CONF_ALWAYS_CALLBACK,
CONF_COLOR,
@@ -424,6 +432,65 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
),
)
def _number_limit_sub_validator(config: dict) -> dict:
"""Validate min, max, and step values for a number entity."""
dpt = config[CONF_GA_SENSOR][CONF_DPT]
transcoder = DPTNumeric.parse_transcoder(dpt)
assert transcoder is not None # already checked by GASelector
return validate_number_attributes(transcoder, config)
NUMBER_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
vol.Required(CONF_GA_SENSOR): GASelector(
write_required=True, dpt=["numeric"]
),
vol.Optional(
CONF_RESPOND_TO_READ, default=False
): selector.BooleanSelector(),
"section_advanced_options": KNXSectionFlat(collapsible=True),
vol.Required(CONF_MODE, default=NumberMode.AUTO): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(NumberMode),
translation_key="component.knx.config_panel.entities.create.number.knx.mode",
),
),
vol.Optional(NumberConf.MIN): selector.NumberSelector(),
vol.Optional(NumberConf.MAX): selector.NumberSelector(),
vol.Optional(NumberConf.STEP): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0, step="any", mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector(
selector.SelectSelectorConfig(
options=sorted(
{
str(unit)
for units in NUMBER_DEVICE_CLASS_UNITS.values()
for unit in units
if unit is not None
}
),
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
),
),
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in NumberDeviceClass],
translation_key="component.knx.selector.sensor_device_class", # should align with sensor
sort=True,
)
),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
},
),
_number_limit_sub_validator,
)
SCENE_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_GA_SCENE): GASelector(
@@ -646,7 +713,7 @@ def _validate_sensor_attributes(config: dict) -> dict:
)
if (
device_class
and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None
and (d_c_units := SENSOR_DEVICE_CLASS_UNITS.get(device_class)) is not None
and unit_of_measurement not in d_c_units
):
raise vol.Invalid(
@@ -687,7 +754,7 @@ SENSOR_KNX_SCHEMA = AllSerializeFirst(
options=sorted(
{
str(unit)
for units in DEVICE_CLASS_UNITS.values()
for units in SENSOR_DEVICE_CLASS_UNITS.values()
for unit in units
if unit is not None
}
@@ -732,6 +799,7 @@ KNX_SCHEMA_FOR_PLATFORM = {
Platform.DATETIME: DATETIME_KNX_SCHEMA,
Platform.FAN: FAN_KNX_SCHEMA,
Platform.LIGHT: LIGHT_KNX_SCHEMA,
Platform.NUMBER: NUMBER_KNX_SCHEMA,
Platform.SCENE: SCENE_KNX_SCHEMA,
Platform.SENSOR: SENSOR_KNX_SCHEMA,
Platform.SWITCH: SWITCH_KNX_SCHEMA,

View File

@@ -802,6 +802,48 @@
}
}
},
"number": {
"description": "Entity for numeric datapoints. Temperature, percent, etc.",
"knx": {
"device_class": {
"description": "[%key:component::knx::config_panel::entities::create::sensor::knx::device_class::description%]",
"label": "[%key:component::knx::config_panel::entities::create::sensor::knx::device_class::label%]"
},
"ga_sensor": {
"description": "Group address representing value.",
"label": "Value"
},
"max": {
"description": "Override the DPT's maximum value allowed to be set.",
"label": "Maximum value"
},
"min": {
"description": "Override the DPT's minimum value allowed to be set.",
"label": "Minimum value"
},
"mode": {
"description": "[%key:component::knx::config_panel::entities::create::text::knx::mode::description%]",
"label": "[%key:common::config_flow::data::mode%]",
"options": {
"auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]",
"box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]",
"slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]"
}
},
"section_advanced_options": {
"description": "Override default DPT-based entity attributes.",
"title": "[%key:component::knx::config_panel::entities::create::sensor::knx::section_advanced_options::title%]"
},
"step": {
"description": "Override the DPT's smallest step size to change the value.",
"label": "Step size"
},
"unit_of_measurement": {
"description": "[%key:component::knx::config_panel::entities::create::sensor::knx::unit_of_measurement::description%]",
"label": "[%key:component::knx::config_panel::entities::create::sensor::knx::unit_of_measurement::label%]"
}
}
},
"scene": {
"description": "A KNX entity can activate a KNX scene and updates when the scene number is received.",
"knx": {
@@ -823,7 +865,7 @@
"label": "Force update"
},
"device_class": {
"description": "Override the DPTs default device class.",
"description": "Override the DPT's default device class.",
"label": "Device class"
},
"ga_sensor": {
@@ -835,11 +877,11 @@
"title": "Overrides"
},
"state_class": {
"description": "Override the DPTs default state class.",
"description": "Override the DPT's default state class.",
"label": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]"
},
"unit_of_measurement": {
"description": "Override the DPTs default unit of measurement.",
"description": "Override the DPT's default unit of measurement.",
"label": "Unit of measurement"
}
}

View File

@@ -3,6 +3,7 @@
from collections.abc import Callable
from enum import Enum
import ipaddress
import math
from typing import Any
import voluptuous as vol
@@ -10,8 +11,15 @@ from xknx.dpt import DPTBase, DPTNumeric, DPTString
from xknx.exceptions import CouldNotParseAddress
from xknx.telegram.address import IndividualAddress, parse_device_group_address
from homeassistant.components.number import (
DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS,
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT
from homeassistant.helpers import config_validation as cv
from .const import NumberConf
from .dpt import get_supported_dpts
def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]:
"""Validate that value is parsable as given sensor type."""
@@ -138,3 +146,76 @@ def backwards_compatible_xknx_climate_enum_member(enumClass: type[Enum]) -> vol.
vol.In(enumClass.__members__),
enumClass.__getitem__,
)
def validate_number_attributes(
transcoder: type[DPTNumeric], config: dict[str, Any]
) -> dict[str, Any]:
"""Validate a number entity configurations dependent on configured value type.
Works for both, UI and YAML configuration schema since they
share same names for all tested attributes.
"""
min_config: float | None = config.get(NumberConf.MIN)
max_config: float | None = config.get(NumberConf.MAX)
step_config: float | None = config.get(NumberConf.STEP)
_dpt_error_str = f"DPT {transcoder.dpt_number_str()} '{transcoder.value_type}'"
# Infinity is not supported by Home Assistant frontend so user defined
# config is required if xknx DPTNumeric subclass defines it as limit.
if min_config is None and transcoder.value_min == -math.inf:
raise vol.Invalid(
f"'min' key required for {_dpt_error_str}",
path=[NumberConf.MIN],
)
if min_config is not None and min_config < transcoder.value_min:
raise vol.Invalid(
f"'min: {min_config}' undercuts possible minimum"
f" of {_dpt_error_str}: {transcoder.value_min}",
path=[NumberConf.MIN],
)
if max_config is None and transcoder.value_max == math.inf:
raise vol.Invalid(
f"'max' key required for {_dpt_error_str}",
path=[NumberConf.MAX],
)
if max_config is not None and max_config > transcoder.value_max:
raise vol.Invalid(
f"'max: {max_config}' exceeds possible maximum"
f" of {_dpt_error_str}: {transcoder.value_max}",
path=[NumberConf.MAX],
)
if step_config is not None and step_config < transcoder.resolution:
raise vol.Invalid(
f"'step: {step_config}' undercuts possible minimum step"
f" of {_dpt_error_str}: {transcoder.resolution}",
path=[NumberConf.STEP],
)
# Validate device class and unit of measurement compatibility
dpt_metadata = get_supported_dpts()[transcoder.dpt_number_str()]
device_class = config.get(
CONF_DEVICE_CLASS,
dpt_metadata["sensor_device_class"],
)
unit_of_measurement = config.get(
CONF_UNIT_OF_MEASUREMENT,
dpt_metadata["unit"],
)
if (
device_class
and (d_c_units := NUMBER_DEVICE_CLASS_UNITS.get(device_class)) is not None
and unit_of_measurement not in d_c_units
):
raise vol.Invalid(
f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. "
f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}",
path=(
[CONF_DEVICE_CLASS]
if CONF_DEVICE_CLASS in config
else [CONF_UNIT_OF_MEASUREMENT]
),
)
return config

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -2,9 +2,10 @@
from __future__ import annotations
from datetime import timedelta
from datetime import datetime, timedelta
import logging
from operator import itemgetter
from typing import Any
import oasatelematics
import voluptuous as vol
@@ -55,9 +56,9 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the OASA Telematics sensor."""
name = config[CONF_NAME]
stop_id = config[CONF_STOP_ID]
route_id = config.get(CONF_ROUTE_ID)
name: str = config[CONF_NAME]
stop_id: str = config[CONF_STOP_ID]
route_id: str = config[CONF_ROUTE_ID]
data = OASATelematicsData(stop_id, route_id)
@@ -68,42 +69,31 @@ class OASATelematicsSensor(SensorEntity):
"""Implementation of the OASA Telematics sensor."""
_attr_attribution = "Data retrieved from telematics.oasa.gr"
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_icon = "mdi:bus"
def __init__(self, data, stop_id, route_id, name):
def __init__(
self, data: OASATelematicsData, stop_id: str, route_id: str, name: str
) -> None:
"""Initialize the sensor."""
self.data = data
self._name = name
self._attr_name = name
self._stop_id = stop_id
self._route_id = route_id
self._name_data = self._times = self._state = None
self._name_data: dict[str, Any] | None = None
self._times: list[dict[str, Any]] | None = None
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def device_class(self) -> SensorDeviceClass:
"""Return the class of this sensor."""
return SensorDeviceClass.TIMESTAMP
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
params = {}
if self._times is not None:
next_arrival_data = self._times[0]
if ATTR_NEXT_ARRIVAL in next_arrival_data:
next_arrival = next_arrival_data[ATTR_NEXT_ARRIVAL]
next_arrival: datetime = next_arrival_data[ATTR_NEXT_ARRIVAL]
params.update({ATTR_NEXT_ARRIVAL: next_arrival.isoformat()})
if len(self._times) > 1:
second_next_arrival_time = self._times[1][ATTR_NEXT_ARRIVAL]
second_next_arrival_time: datetime = self._times[1][ATTR_NEXT_ARRIVAL]
if second_next_arrival_time is not None:
second_arrival = second_next_arrival_time
params.update(
@@ -115,12 +105,13 @@ class OASATelematicsSensor(SensorEntity):
ATTR_STOP_ID: self._stop_id,
}
)
params.update(
{
ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME],
ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME],
}
)
if self._name_data is not None:
params.update(
{
ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME],
ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME],
}
)
return {k: v for k, v in params.items() if v}
def update(self) -> None:
@@ -130,7 +121,7 @@ class OASATelematicsSensor(SensorEntity):
self._name_data = self.data.name_data
next_arrival_data = self._times[0]
if ATTR_NEXT_ARRIVAL in next_arrival_data:
self._state = next_arrival_data[ATTR_NEXT_ARRIVAL]
self._attr_native_value = next_arrival_data[ATTR_NEXT_ARRIVAL]
class OASATelematicsData:

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

@@ -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()
entry.runtime_data.client.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__)
@@ -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"

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