mirror of
https://github.com/home-assistant/core.git
synced 2026-01-17 21:16:53 +01:00
Compare commits
33 Commits
adjust_ent
...
tibber_lib
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3198cf28e4 | ||
|
|
e26d90d82b | ||
|
|
da52482365 | ||
|
|
6ba16ee9e9 | ||
|
|
fa29d8180f | ||
|
|
5d43efb22d | ||
|
|
3539c4bcec | ||
|
|
3e3ec4616c | ||
|
|
907861effd | ||
|
|
862a2bc95c | ||
|
|
60f498c1fa | ||
|
|
bb3617ac08 | ||
|
|
48d1bd13fa | ||
|
|
8555bc9da0 | ||
|
|
9260394883 | ||
|
|
8503637a80 | ||
|
|
c993cd9bee | ||
|
|
171013c0d0 | ||
|
|
c8a7aa359e | ||
|
|
88d8951657 | ||
|
|
b66ab3cf92 | ||
|
|
253b32abd6 | ||
|
|
cc20072c86 | ||
|
|
f86db56d48 | ||
|
|
3e2ebb8ebb | ||
|
|
6e7b206788 | ||
|
|
cee007b0b0 | ||
|
|
bd24c27bc9 | ||
|
|
49bd26da86 | ||
|
|
49c42b9ad0 | ||
|
|
411491dc45 | ||
|
|
47383a499e | ||
|
|
f9aa307cb2 |
@@ -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/**
|
||||
|
||||
@@ -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
7
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"hysteresis_band": {
|
||||
"default": "mdi:delta"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"actuator_exercise_disabled": {
|
||||
"default": "mdi:valve"
|
||||
},
|
||||
"child_lock": {
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
homeassistant/components/airobot/switch.py
Normal file
118
homeassistant/components/airobot/switch.py
Normal 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()
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260107.1"]
|
||||
"requirements": ["home-assistant-frontend==20260107.2"]
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
NAME = "JVC Projector"
|
||||
DOMAIN = "jvc_projector"
|
||||
MANUFACTURER = "JVC"
|
||||
|
||||
POWER = "power"
|
||||
INPUT = "input"
|
||||
SOURCE = "source"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.1.3"]
|
||||
"requirements": ["pyjvcprojector==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
128
homeassistant/components/mastodon/binary_sensor.py
Normal file
128
homeassistant/components/mastodon/binary_sensor.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
"boost_state": {
|
||||
"name": "Boost state"
|
||||
},
|
||||
"config_status_operational": {
|
||||
"name": "Configuration status"
|
||||
},
|
||||
"dishwasher_alarm_inflow": {
|
||||
"name": "Inflow alarm"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiomealie==1.1.1"]
|
||||
"requirements": ["aiomealie==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -84,4 +84,5 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"minimum_minecraft_version": "1.4"},
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
175
homeassistant/components/proxmoxve/config_flow.py
Normal file
175
homeassistant/components/proxmoxve/config_flow.py
Normal 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."""
|
||||
@@ -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__)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"domain": "proxmoxve",
|
||||
"name": "Proxmox VE",
|
||||
"codeowners": ["@jhollowe", "@Corbeno"],
|
||||
"codeowners": ["@jhollowe", "@Corbeno", "@erwindouna"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/proxmoxve",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["proxmoxer"],
|
||||
|
||||
46
homeassistant/components/proxmoxve/strings.json
Normal file
46
homeassistant/components/proxmoxve/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -14,7 +14,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 +30,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)
|
||||
|
||||
13
homeassistant/components/qwikswitch/const.py
Normal file
13
homeassistant/components/qwikswitch/const.py
Normal 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)
|
||||
@@ -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):
|
||||
@@ -67,8 +67,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ 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
|
||||
@@ -57,6 +57,8 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_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]
|
||||
@@ -143,10 +145,18 @@ 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()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pysaunum import SaunumException
|
||||
|
||||
@@ -15,6 +15,9 @@ from . import LeilSaunaConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import LeilSaunaEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import LeilSaunaCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@@ -35,7 +38,7 @@ class LeilSaunaLight(LeilSaunaEntity, LightEntity):
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
def __init__(self, coordinator) -> None:
|
||||
def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
|
||||
"""Initialize the light entity."""
|
||||
super().__init__(coordinator)
|
||||
# Override unique_id to differentiate from climate entity
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pysaunum"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pysaunum==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -133,11 +133,7 @@ class LeilSaunaNumber(LeilSaunaEntity, NumberEntity):
|
||||
except SaunumException as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_value_failed",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_description.key,
|
||||
"value": str(value),
|
||||
},
|
||||
translation_key=f"set_{self.entity_description.key}_failed",
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -77,4 +77,4 @@ rules:
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: Integration uses Modbus TCP protocol and does not make HTTP requests.
|
||||
strict-typing: todo
|
||||
strict-typing: done
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
"session_not_active": {
|
||||
"message": "Cannot change fan mode when sauna session is not active"
|
||||
},
|
||||
"set_fan_duration_failed": {
|
||||
"message": "Failed to set fan duration"
|
||||
},
|
||||
"set_fan_mode_failed": {
|
||||
"message": "Failed to set fan mode"
|
||||
},
|
||||
"set_hvac_mode_failed": {
|
||||
"message": "Failed to set HVAC mode to {hvac_mode}"
|
||||
},
|
||||
@@ -98,11 +104,11 @@
|
||||
"set_light_on_failed": {
|
||||
"message": "Failed to turn on light"
|
||||
},
|
||||
"set_sauna_duration_failed": {
|
||||
"message": "Failed to set sauna duration"
|
||||
},
|
||||
"set_temperature_failed": {
|
||||
"message": "Failed to set temperature to {temperature}"
|
||||
},
|
||||
"set_value_failed": {
|
||||
"message": "Failed to set {entity} to {value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, cast
|
||||
|
||||
from aioshelly.const import RPC_GENERATIONS
|
||||
from aioshelly.const import MODEL_FLOOD_G4, RPC_GENERATIONS
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_PLATFORM,
|
||||
@@ -335,6 +335,7 @@ RPC_SENSORS: Final = {
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
supported=lambda status: status.get("alarm") is not None,
|
||||
models={MODEL_FLOOD_G4},
|
||||
),
|
||||
"presence_num_objects": RpcBinarySensorDescription(
|
||||
key="presence",
|
||||
|
||||
@@ -144,6 +144,51 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconf_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
errors, device_info = await self._handle_user_input(
|
||||
user_input={
|
||||
**reconf_entry.data,
|
||||
**user_input,
|
||||
}
|
||||
)
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(
|
||||
str(device_info["serial"]), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reconf_entry,
|
||||
data_updates={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_SSL: user_input[CONF_SSL],
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
CONF_GROUP: user_input[CONF_GROUP],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_SSL): cv.boolean,
|
||||
vol.Optional(CONF_VERIFY_SSL): cv.boolean,
|
||||
vol.Optional(CONF_GROUP): vol.In(GROUPS),
|
||||
}
|
||||
),
|
||||
suggested_values=user_input or dict(reconf_entry.data),
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "You selected a different SMA device than the one this config entry was configured with, this is not allowed."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -29,6 +31,16 @@
|
||||
"description": "The SMA integration needs to re-authenticate your connection details",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"group": "[%key:component::sma::config::step::user::data::group%]",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"description": "Use the following form to reconfigure your SMA device.",
|
||||
"title": "Reconfigure SMA Solar Integration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"group": "Group",
|
||||
@@ -44,5 +56,13 @@
|
||||
"title": "Set up SMA Solar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"group": {
|
||||
"options": {
|
||||
"installer": "Installer",
|
||||
"user": "User"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,12 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
|
||||
from .types import DaliCenterConfigEntry, DaliCenterData
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.LIGHT, Platform.SCENE]
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SCENE,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
121
homeassistant/components/sunricher_dali/sensor.py
Normal file
121
homeassistant/components/sunricher_dali/sensor.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Platform for Sunricher DALI sensor entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PySrDaliGateway import CallbackEventType, Device
|
||||
from PySrDaliGateway.helper import is_illuminance_sensor
|
||||
from PySrDaliGateway.types import IlluminanceStatus
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import LIGHT_LUX
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .entity import DaliDeviceEntity
|
||||
from .types import DaliCenterConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: DaliCenterConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Sunricher DALI sensor entities from config entry."""
|
||||
devices = entry.runtime_data.devices
|
||||
|
||||
entities: list[SensorEntity] = [
|
||||
SunricherDaliIlluminanceSensor(device)
|
||||
for device in devices
|
||||
if is_illuminance_sensor(device.dev_type)
|
||||
]
|
||||
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class SunricherDaliIlluminanceSensor(DaliDeviceEntity, SensorEntity):
|
||||
"""Representation of a Sunricher DALI Illuminance Sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ILLUMINANCE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = LIGHT_LUX
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, device: Device) -> None:
|
||||
"""Initialize the illuminance sensor."""
|
||||
super().__init__(device)
|
||||
self._device = device
|
||||
self._illuminance_value: float | None = None
|
||||
self._sensor_enabled: bool = True
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.dev_id)},
|
||||
name=device.name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=device.model,
|
||||
via_device=(DOMAIN, device.gw_sn),
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the native value, or None if sensor is disabled."""
|
||||
if not self._sensor_enabled:
|
||||
return None
|
||||
return self._illuminance_value
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity addition to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.async_on_remove(
|
||||
self._device.register_listener(
|
||||
CallbackEventType.ILLUMINANCE_STATUS, self._handle_illuminance_status
|
||||
)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
self._device.register_listener(
|
||||
CallbackEventType.SENSOR_ON_OFF, self._handle_sensor_on_off
|
||||
)
|
||||
)
|
||||
|
||||
self._device.read_status()
|
||||
|
||||
@callback
|
||||
def _handle_illuminance_status(self, status: IlluminanceStatus) -> None:
|
||||
"""Handle illuminance status updates."""
|
||||
illuminance_value = status["illuminance_value"]
|
||||
is_valid = status["is_valid"]
|
||||
|
||||
if not is_valid:
|
||||
_LOGGER.debug(
|
||||
"Illuminance value is not valid for device %s: %s lux",
|
||||
self._device.dev_id,
|
||||
illuminance_value,
|
||||
)
|
||||
return
|
||||
|
||||
self._illuminance_value = illuminance_value
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_sensor_on_off(self, on_off: bool) -> None:
|
||||
"""Handle sensor on/off updates."""
|
||||
self._sensor_enabled = on_off
|
||||
_LOGGER.debug(
|
||||
"Illuminance sensor enable state for device %s updated to: %s",
|
||||
self._device.dev_id,
|
||||
on_off,
|
||||
)
|
||||
self.schedule_update_ha_state()
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.34.4"]
|
||||
"requirements": ["pyTibber==0.35.0"]
|
||||
}
|
||||
|
||||
@@ -282,8 +282,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity):
|
||||
self._attr_target_temperature = temp
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
if self._current_program is None:
|
||||
return None
|
||||
return HeatingProgram.to_ha_preset(self._current_program)
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["xiaomi-ble==1.4.1"]
|
||||
"requirements": ["xiaomi-ble==1.4.3"]
|
||||
}
|
||||
|
||||
@@ -840,19 +840,26 @@ class NodeEvents:
|
||||
# After ensuring the node is set up in HA, we should check if the node's
|
||||
# device config has changed, and if so, issue a repair registry entry for a
|
||||
# possible reinterview
|
||||
if not node.is_controller_node and await node.async_has_device_config_changed():
|
||||
device_name = device.name_by_user or device.name or "Unnamed device"
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"device_config_file_changed.{device.id}",
|
||||
data={"device_id": device.id, "device_name": device_name},
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
translation_key="device_config_file_changed",
|
||||
translation_placeholders={"device_name": device_name},
|
||||
severity=IssueSeverity.WARNING,
|
||||
)
|
||||
if not node.is_controller_node:
|
||||
issue_id = f"device_config_file_changed.{device.id}"
|
||||
if await node.async_has_device_config_changed():
|
||||
device_name = device.name_by_user or device.name or "Unnamed device"
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
data={"device_id": device.id, "device_name": device_name},
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
translation_key="device_config_file_changed",
|
||||
translation_placeholders={"device_name": device_name},
|
||||
severity=IssueSeverity.WARNING,
|
||||
)
|
||||
else:
|
||||
# Clear any existing repair issue if the device config is not considered
|
||||
# changed. This can happen when the original issue was created by
|
||||
# an upstream bug, or the change has been reverted.
|
||||
async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
|
||||
async def async_handle_discovery_info(
|
||||
self,
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -537,6 +537,7 @@ FLOWS = {
|
||||
"prosegur",
|
||||
"prowl",
|
||||
"proximity",
|
||||
"proxmoxve",
|
||||
"prusalink",
|
||||
"ps4",
|
||||
"pterodactyl",
|
||||
|
||||
@@ -5246,7 +5246,7 @@
|
||||
"proxmoxve": {
|
||||
"name": "Proxmox VE",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"proxy": {
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==5.8.0
|
||||
hass-nabucasa==1.9.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260107.1
|
||||
home-assistant-frontend==20260107.2
|
||||
home-assistant-intents==2026.1.6
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -4306,6 +4306,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.saunum.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.scene.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
@@ -1225,6 +1225,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="preset_mode",
|
||||
return_type=["str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="preset_modes",
|
||||
@@ -1602,6 +1603,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="preset_mode",
|
||||
return_type=["str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="preset_modes",
|
||||
|
||||
12
requirements_all.txt
generated
12
requirements_all.txt
generated
@@ -319,7 +319,7 @@ aiolookin==1.0.0
|
||||
aiolyric==2.0.2
|
||||
|
||||
# homeassistant.components.mealie
|
||||
aiomealie==1.1.1
|
||||
aiomealie==1.2.0
|
||||
|
||||
# homeassistant.components.modern_forms
|
||||
aiomodernforms==0.1.8
|
||||
@@ -1215,7 +1215,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260107.1
|
||||
home-assistant-frontend==20260107.2
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.6
|
||||
@@ -1454,7 +1454,7 @@ mbddns==0.1.2
|
||||
mcp==1.14.1
|
||||
|
||||
# homeassistant.components.minecraft_server
|
||||
mcstatus==12.0.6
|
||||
mcstatus==12.1.0
|
||||
|
||||
# homeassistant.components.meater
|
||||
meater-python==0.0.8
|
||||
@@ -1866,7 +1866,7 @@ pyRFXtrx==0.31.1
|
||||
pySDCP==1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.4
|
||||
pyTibber==0.35.0
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -2132,7 +2132,7 @@ pyitachip2ir==0.0.7
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==1.1.3
|
||||
pyjvcprojector==2.0.0
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.0.2
|
||||
@@ -3215,7 +3215,7 @@ wsdot==0.0.1
|
||||
wyoming==1.7.2
|
||||
|
||||
# homeassistant.components.xiaomi_ble
|
||||
xiaomi-ble==1.4.1
|
||||
xiaomi-ble==1.4.3
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.14.0
|
||||
|
||||
15
requirements_test_all.txt
generated
15
requirements_test_all.txt
generated
@@ -304,7 +304,7 @@ aiolookin==1.0.0
|
||||
aiolyric==2.0.2
|
||||
|
||||
# homeassistant.components.mealie
|
||||
aiomealie==1.1.1
|
||||
aiomealie==1.2.0
|
||||
|
||||
# homeassistant.components.modern_forms
|
||||
aiomodernforms==0.1.8
|
||||
@@ -1073,7 +1073,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260107.1
|
||||
home-assistant-frontend==20260107.2
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.6
|
||||
@@ -1267,7 +1267,7 @@ mbddns==0.1.2
|
||||
mcp==1.14.1
|
||||
|
||||
# homeassistant.components.minecraft_server
|
||||
mcstatus==12.0.6
|
||||
mcstatus==12.1.0
|
||||
|
||||
# homeassistant.components.meater
|
||||
meater-python==0.0.8
|
||||
@@ -1522,6 +1522,9 @@ prometheus-client==0.21.0
|
||||
# homeassistant.components.prowl
|
||||
prowlpy==1.1.1
|
||||
|
||||
# homeassistant.components.proxmoxve
|
||||
proxmoxer==2.0.1
|
||||
|
||||
# homeassistant.components.hardware
|
||||
# homeassistant.components.recorder
|
||||
# homeassistant.components.systemmonitor
|
||||
@@ -1597,7 +1600,7 @@ pyHomee==1.3.8
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.4
|
||||
pyTibber==0.35.0
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -1803,7 +1806,7 @@ pyisy==3.4.1
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==1.1.3
|
||||
pyjvcprojector==2.0.0
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.0.2
|
||||
@@ -2688,7 +2691,7 @@ wsdot==0.0.1
|
||||
wyoming==1.7.2
|
||||
|
||||
# homeassistant.components.xiaomi_ble
|
||||
xiaomi-ble==1.4.1
|
||||
xiaomi-ble==1.4.3
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.14.0
|
||||
|
||||
99
tests/components/airobot/snapshots/test_switch.ambr
Normal file
99
tests/components/airobot/snapshots/test_switch.ambr
Normal file
@@ -0,0 +1,99 @@
|
||||
# serializer version: 1
|
||||
# name: test_switches[switch.test_thermostat_actuator_exercise_disabled-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.test_thermostat_actuator_exercise_disabled',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Actuator exercise disabled',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Actuator exercise disabled',
|
||||
'platform': 'airobot',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'actuator_exercise_disabled',
|
||||
'unique_id': 'T01A1B2C3_actuator_exercise_disabled',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[switch.test_thermostat_actuator_exercise_disabled-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Thermostat Actuator exercise disabled',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.test_thermostat_actuator_exercise_disabled',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[switch.test_thermostat_child_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.test_thermostat_child_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Child lock',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Child lock',
|
||||
'platform': 'airobot',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'child_lock',
|
||||
'unique_id': 'T01A1B2C3_child_lock',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[switch.test_thermostat_child_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Thermostat Child lock',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.test_thermostat_child_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
177
tests/components/airobot/test_switch.py
Normal file
177
tests/components/airobot/test_switch.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Tests for the Airobot switch platform."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pyairobotrest.exceptions import AirobotError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return [Platform.SWITCH]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_switches(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the switch entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "method_name"),
|
||||
[
|
||||
("switch.test_thermostat_child_lock", "set_child_lock"),
|
||||
(
|
||||
"switch.test_thermostat_actuator_exercise_disabled",
|
||||
"toggle_actuator_exercise",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_switch_turn_on_off(
|
||||
hass: HomeAssistant,
|
||||
mock_airobot_client: AsyncMock,
|
||||
entity_id: str,
|
||||
method_name: str,
|
||||
) -> None:
|
||||
"""Test switch turn on/off functionality."""
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
mock_method = getattr(mock_airobot_client, method_name)
|
||||
|
||||
# Turn on
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_method.assert_called_once_with(True)
|
||||
mock_method.reset_mock()
|
||||
|
||||
# Turn off
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_method.assert_called_once_with(False)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_switch_state_updates(
|
||||
hass: HomeAssistant,
|
||||
mock_airobot_client: AsyncMock,
|
||||
mock_settings,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that switch state updates when coordinator refreshes."""
|
||||
# Initial state - both switches off
|
||||
child_lock = hass.states.get("switch.test_thermostat_child_lock")
|
||||
assert child_lock is not None
|
||||
assert child_lock.state == STATE_OFF
|
||||
|
||||
actuator_disabled = hass.states.get(
|
||||
"switch.test_thermostat_actuator_exercise_disabled"
|
||||
)
|
||||
assert actuator_disabled is not None
|
||||
assert actuator_disabled.state == STATE_OFF
|
||||
|
||||
# Update settings to enable both
|
||||
mock_settings.setting_flags.childlock_enabled = True
|
||||
mock_settings.setting_flags.actuator_exercise_disabled = True
|
||||
mock_airobot_client.get_settings.return_value = mock_settings
|
||||
|
||||
# Trigger coordinator update
|
||||
await mock_config_entry.runtime_data.async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify states updated
|
||||
child_lock = hass.states.get("switch.test_thermostat_child_lock")
|
||||
assert child_lock is not None
|
||||
assert child_lock.state == STATE_ON
|
||||
|
||||
actuator_disabled = hass.states.get(
|
||||
"switch.test_thermostat_actuator_exercise_disabled"
|
||||
)
|
||||
assert actuator_disabled is not None
|
||||
assert actuator_disabled.state == STATE_ON
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "method_name", "service", "expected_key"),
|
||||
[
|
||||
(
|
||||
"switch.test_thermostat_child_lock",
|
||||
"set_child_lock",
|
||||
SERVICE_TURN_ON,
|
||||
"child_lock",
|
||||
),
|
||||
(
|
||||
"switch.test_thermostat_child_lock",
|
||||
"set_child_lock",
|
||||
SERVICE_TURN_OFF,
|
||||
"child_lock",
|
||||
),
|
||||
(
|
||||
"switch.test_thermostat_actuator_exercise_disabled",
|
||||
"toggle_actuator_exercise",
|
||||
SERVICE_TURN_ON,
|
||||
"actuator_exercise_disabled",
|
||||
),
|
||||
(
|
||||
"switch.test_thermostat_actuator_exercise_disabled",
|
||||
"toggle_actuator_exercise",
|
||||
SERVICE_TURN_OFF,
|
||||
"actuator_exercise_disabled",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_switch_error_handling(
|
||||
hass: HomeAssistant,
|
||||
mock_airobot_client: AsyncMock,
|
||||
entity_id: str,
|
||||
method_name: str,
|
||||
service: str,
|
||||
expected_key: str,
|
||||
) -> None:
|
||||
"""Test switch error handling for turn on/off operations."""
|
||||
mock_method = getattr(mock_airobot_client, method_name)
|
||||
mock_method.side_effect = AirobotError("Test error")
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=expected_key):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
expected_value = service == SERVICE_TURN_ON
|
||||
mock_method.assert_called_once_with(expected_value)
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for analytics platform."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.analytics import async_devices_payload
|
||||
from homeassistant.components.esphome import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -11,7 +9,6 @@ from homeassistant.setup import async_setup_component
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics(
|
||||
hass: HomeAssistant, device_registry: dr.DeviceRegistry
|
||||
) -> None:
|
||||
|
||||
@@ -52,7 +52,6 @@ async def test_async_setup_entry_errors(
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_setup_entry_success(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MagicMock,
|
||||
@@ -67,7 +66,6 @@ async def test_async_setup_entry_success(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MagicMock,
|
||||
@@ -87,7 +85,6 @@ async def test_async_unload_entry(
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platforms_forwarded(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MagicMock,
|
||||
|
||||
@@ -6,7 +6,8 @@ from hdfury import HDFuryError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
@@ -47,9 +48,9 @@ async def test_button_presses(
|
||||
await setup_integration(hass, mock_config_entry, [Platform.BUTTON])
|
||||
|
||||
await hass.services.async_call(
|
||||
"button",
|
||||
"press",
|
||||
{"entity_id": entity_id},
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -67,10 +68,13 @@ async def test_button_press_error(
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.BUTTON])
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="An error occurred while communicating with HDFury device",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"button",
|
||||
"press",
|
||||
{"entity_id": "button.hdfury_vrroom_02_restart"},
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.hdfury_vrroom_02_restart"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
"""Tests for the HDFury select platform."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from hdfury import HDFuryError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.components.select import (
|
||||
DOMAIN as SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
async def test_select_entities(
|
||||
@@ -21,3 +32,133 @@ async def test_select_entities(
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SELECT])
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_select_operation_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_hdfury_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test selecting operation mode."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SELECT])
|
||||
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{
|
||||
ATTR_ENTITY_ID: "select.hdfury_vrroom_02_operation_mode",
|
||||
ATTR_OPTION: "1",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_hdfury_client.set_operation_mode.assert_awaited_once_with("1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id"),
|
||||
[
|
||||
("select.hdfury_vrroom_02_port_select_tx0"),
|
||||
("select.hdfury_vrroom_02_port_select_tx1"),
|
||||
],
|
||||
)
|
||||
async def test_select_tx_ports(
|
||||
hass: HomeAssistant,
|
||||
mock_hdfury_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_id: str,
|
||||
) -> None:
|
||||
"""Test selecting TX ports."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SELECT])
|
||||
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_OPTION: "1",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_hdfury_client.set_port_selection.assert_awaited()
|
||||
|
||||
|
||||
async def test_select_operation_mode_error(
|
||||
hass: HomeAssistant,
|
||||
mock_hdfury_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test operation mode select raises HomeAssistantError."""
|
||||
|
||||
mock_hdfury_client.set_operation_mode.side_effect = HDFuryError()
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SELECT])
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="An error occurred while communicating with HDFury device",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{
|
||||
ATTR_ENTITY_ID: "select.hdfury_vrroom_02_operation_mode",
|
||||
ATTR_OPTION: "1",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_select_ports_missing_state(
|
||||
hass: HomeAssistant,
|
||||
mock_hdfury_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test TX port selection fails when TX state is incomplete."""
|
||||
|
||||
mock_hdfury_client.get_info.return_value = {
|
||||
"portseltx0": "0",
|
||||
"portseltx1": None,
|
||||
"opmode": "0",
|
||||
}
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SELECT])
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="An error occurred while validating TX states",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{
|
||||
ATTR_ENTITY_ID: "select.hdfury_vrroom_02_port_select_tx0",
|
||||
ATTR_OPTION: "0",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_select_entities_unavailable_on_error(
|
||||
hass: HomeAssistant,
|
||||
mock_hdfury_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test API error causes entities to become unavailable."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SELECT])
|
||||
|
||||
mock_hdfury_client.get_info.side_effect = HDFuryError()
|
||||
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
hass.states.get("select.hdfury_vrroom_02_port_select_tx0").state
|
||||
== STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
"""Tests for the HDFury switch platform."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from hdfury import HDFuryError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
async def test_switch_entities(
|
||||
@@ -34,15 +43,15 @@ async def test_switch_entities(
|
||||
(
|
||||
"switch.hdfury_vrroom_02_auto_switch_inputs",
|
||||
"set_auto_switch_inputs",
|
||||
"turn_on",
|
||||
SERVICE_TURN_ON,
|
||||
),
|
||||
(
|
||||
"switch.hdfury_vrroom_02_auto_switch_inputs",
|
||||
"set_auto_switch_inputs",
|
||||
"turn_off",
|
||||
SERVICE_TURN_OFF,
|
||||
),
|
||||
("switch.hdfury_vrroom_02_oled_display", "set_oled", "turn_on"),
|
||||
("switch.hdfury_vrroom_02_oled_display", "set_oled", "turn_off"),
|
||||
("switch.hdfury_vrroom_02_oled_display", "set_oled", SERVICE_TURN_ON),
|
||||
("switch.hdfury_vrroom_02_oled_display", "set_oled", SERVICE_TURN_OFF),
|
||||
],
|
||||
)
|
||||
async def test_switch_turn_on_off(
|
||||
@@ -58,9 +67,9 @@ async def test_switch_turn_on_off(
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
|
||||
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
SWITCH_DOMAIN,
|
||||
service,
|
||||
{"entity_id": entity_id},
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -70,8 +79,8 @@ async def test_switch_turn_on_off(
|
||||
@pytest.mark.parametrize(
|
||||
("service", "method"),
|
||||
[
|
||||
("turn_on", "set_auto_switch_inputs"),
|
||||
("turn_off", "set_auto_switch_inputs"),
|
||||
(SERVICE_TURN_ON, "set_auto_switch_inputs"),
|
||||
(SERVICE_TURN_OFF, "set_auto_switch_inputs"),
|
||||
],
|
||||
)
|
||||
async def test_switch_turn_error(
|
||||
@@ -92,8 +101,30 @@ async def test_switch_turn_error(
|
||||
match="An error occurred while communicating with HDFury device",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
SWITCH_DOMAIN,
|
||||
service,
|
||||
{"entity_id": "switch.hdfury_vrroom_02_auto_switch_inputs"},
|
||||
{ATTR_ENTITY_ID: "switch.hdfury_vrroom_02_auto_switch_inputs"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_switch_entities_unavailable_on_error(
|
||||
hass: HomeAssistant,
|
||||
mock_hdfury_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test API error causes entities to become unavailable."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry, [Platform.SWITCH])
|
||||
|
||||
mock_hdfury_client.get_info.side_effect = HDFuryError()
|
||||
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
hass.states.get("switch.hdfury_vrroom_02_auto_switch_inputs").state
|
||||
== STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
@@ -38,7 +38,6 @@ async def test_sensors(
|
||||
ValueError,
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_sensor_unavailable_on_update_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
MOCK_HOST = "127.0.0.1"
|
||||
MOCK_PORT = 20554
|
||||
MOCK_PASSWORD = "jvcpasswd"
|
||||
MOCK_MAC = "jvcmac"
|
||||
MOCK_MODEL = "jvcmodel"
|
||||
MOCK_MAC = "E0DADC0A1234"
|
||||
MOCK_MAC_FORMATED = "e0:da:dc:0a:12:34"
|
||||
MOCK_MODEL = "B2A2"
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from jvcprojector import command as cmd
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.jvc_projector.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from . import MOCK_HOST, MOCK_MAC, MOCK_MODEL, MOCK_PASSWORD, MOCK_PORT
|
||||
|
||||
@@ -20,16 +22,31 @@ def fixture_mock_device(
|
||||
) -> Generator[MagicMock]:
|
||||
"""Return a mocked JVC Projector device."""
|
||||
target = "homeassistant.components.jvc_projector.JvcProjector"
|
||||
fixture: dict[str, str] = {
|
||||
"mac": MOCK_MAC,
|
||||
"power": "standby",
|
||||
"input": "hdmi-1",
|
||||
}
|
||||
|
||||
if hasattr(request, "param"):
|
||||
target = request.param
|
||||
target = request.param.get("target", target)
|
||||
fixture = request.param.get("get", fixture)
|
||||
|
||||
async def device_get(command) -> str:
|
||||
if command is cmd.MacAddress:
|
||||
return fixture["mac"]
|
||||
if command is cmd.Power:
|
||||
return fixture["power"]
|
||||
if command is cmd.Input:
|
||||
return fixture["input"]
|
||||
raise ValueError(f"Fixture failure; unexpected command {command}")
|
||||
|
||||
with patch(target, autospec=True) as mock:
|
||||
device = mock.return_value
|
||||
device.host = MOCK_HOST
|
||||
device.port = MOCK_PORT
|
||||
device.mac = MOCK_MAC
|
||||
device.model = MOCK_MODEL
|
||||
device.get_state.return_value = {"power": "standby", "input": "hdmi1"}
|
||||
device.get.side_effect = device_get
|
||||
yield device
|
||||
|
||||
|
||||
@@ -38,7 +55,7 @@ def fixture_mock_config_entry() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=MOCK_MAC,
|
||||
unique_id=format_mac(MOCK_MAC),
|
||||
version=1,
|
||||
data={
|
||||
CONF_HOST: MOCK_HOST,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user