Compare commits

..

1 Commits

Author SHA1 Message Date
Erik
7917f0a2b9 Adjust entity condition strings 2026-01-16 11:04:55 +01:00
275 changed files with 13746 additions and 25701 deletions

View File

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

View File

@@ -310,7 +310,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-python-venv >-
@@ -374,7 +374,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -425,7 +425,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: *path-apt-cache
fail-on-cache-miss: true

View File

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

View File

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

View File

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

7
CODEOWNERS generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed"
"name": "Alarm is armed"
},
"is_armed_away": {
"description": "Tests if one or more alarms are armed in away mode.",
@@ -24,7 +24,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed away"
"name": "Alarm is armed away"
},
"is_armed_home": {
"description": "Tests if one or more alarms are armed in home mode.",
@@ -34,7 +34,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed home"
"name": "Alarm is armed home"
},
"is_armed_night": {
"description": "Tests if one or more alarms are armed in night mode.",
@@ -44,7 +44,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed night"
"name": "Alarm is armed night"
},
"is_armed_vacation": {
"description": "Tests if one or more alarms are armed in vacation mode.",
@@ -54,7 +54,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed vacation"
"name": "Alarm is armed vacation"
},
"is_disarmed": {
"description": "Tests if one or more alarms are disarmed.",
@@ -64,7 +64,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is disarmed"
"name": "Alarm is disarmed"
},
"is_triggered": {
"description": "Tests if one or more alarms are triggered.",
@@ -74,7 +74,7 @@
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is triggered"
"name": "Alarm is triggered"
}
},
"device_automation": {

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is idle"
"name": "Satellite is idle"
},
"is_listening": {
"description": "Tests if one or more Assist satellites are listening.",
@@ -24,7 +24,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is listening"
"name": "Satellite is listening"
},
"is_processing": {
"description": "Tests if one or more Assist satellites are processing.",
@@ -34,7 +34,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is processing"
"name": "Satellite is processing"
},
"is_responding": {
"description": "Tests if one or more Assist satellites are responding.",
@@ -44,7 +44,7 @@
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is responding"
"name": "Satellite is responding"
}
},
"entity_component": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is off"
"name": "Fan is off"
},
"is_on": {
"description": "Tests if one or more fans are on.",
@@ -24,7 +24,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is on"
"name": "Fan is on"
}
},
"device_automation": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,6 @@ 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):
@@ -79,11 +77,13 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entities: list[HDFuryEntity] = [
HDFurySelect(coordinator, description)
for description in SELECT_PORTS
if description.key in coordinator.data.info
]
entities: list[HDFuryEntity] = []
for description in SELECT_PORTS:
if description.key not in coordinator.data.info:
continue
entities.append(HDFurySelect(coordinator, description))
# Add OPMODE select if present
if "opmode" in coordinator.data.info:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,17 +2,16 @@
from __future__ import annotations
from jvcprojector import command as cmd
from jvcprojector import const
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 = (cmd.Power.ON, cmd.Power.WARMING)
ON_STATUS = (const.ON, const.WARMING)
async def async_setup_entry(
@@ -22,13 +21,14 @@ 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 = "power"
_attr_translation_key = "jvc_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.unique_id}_power"
self._attr_unique_id = f"{coordinator.device.mac}_power"
@property
def is_on(self) -> bool:
"""Return true if the JVC Projector is on."""
return self.coordinator.data[POWER] in ON_STATUS
"""Return true if the JVC is on."""
return self.coordinator.data["power"] in ON_STATUS

View File

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

View File

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

View File

@@ -4,21 +4,22 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from jvcprojector import (
JvcProjector,
JvcProjectorAuthError,
JvcProjectorTimeoutError,
command as cmd,
JvcProjectorConnectError,
const,
)
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 INPUT, NAME, POWER
from .const import NAME
_LOGGER = logging.getLogger(__name__)
@@ -45,33 +46,26 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
update_interval=INTERVAL_SLOW,
)
self.device: JvcProjector = device
if TYPE_CHECKING:
assert config_entry.unique_id is not None
self.unique_id = config_entry.unique_id
self.device = device
self.unique_id = format_mac(device.mac)
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[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:
state = await self.device.get_state()
except JvcProjectorConnectError 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
if state[POWER] != cmd.Power.STANDBY:
old_interval = self.update_interval
if state[const.POWER] != const.STANDBY:
self.update_interval = INTERVAL_FAST
else:
self.update_interval = INTERVAL_SLOW
if self.update_interval != old_interval:
_LOGGER.debug("Changed update interval to %s", self.update_interval)
return state

View File

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

View File

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

View File

@@ -7,62 +7,54 @@ from collections.abc import Iterable
import logging
from typing import Any
from jvcprojector import command as cmd
from jvcprojector import const
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: 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,
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,
}
_LOGGER = logging.getLogger(__name__)
@@ -85,34 +77,25 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity):
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
return self.coordinator.data[POWER] in (cmd.Power.ON, cmd.Power.WARMING)
"""Return True if entity is on."""
return self.coordinator.data["power"] in [const.ON, const.WARMING]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await self.device.set(cmd.Power, cmd.Power.ON)
await self.device.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.set(cmd.Power, cmd.Power.OFF)
await self.device.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 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)
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])

View File

@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Final
from jvcprojector import JvcProjector, command as cmd
from jvcprojector import JvcProjector, const
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
@@ -23,12 +23,16 @@ 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=[cmd.Input.HDMI1, cmd.Input.HDMI2],
command=lambda device, option: device.set(cmd.Input, option),
options=list(OPTIONS["input"]),
command=lambda device, option: device.remote(OPTIONS["input"][option]),
)
]

View File

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

View File

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

View File

@@ -18,11 +18,7 @@ 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,
async_update_preview_feature,
)
from .helpers import async_is_preview_feature_enabled, async_listen
from .models import (
EventLabsUpdatedData,
LabPreviewFeature,
@@ -41,7 +37,6 @@ __all__ = [
"EventLabsUpdatedData",
"async_is_preview_feature_enabled",
"async_listen",
"async_update_preview_feature",
]

View File

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

View File

@@ -8,14 +8,12 @@ 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,
async_update_preview_feature,
)
from .helpers import async_is_preview_feature_enabled, async_listen
from .models import EventLabsUpdatedData
@callback
@@ -97,7 +95,19 @@ async def websocket_update_preview_feature(
)
return
await async_update_preview_feature(hass, domain, preview_feature, enabled)
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)
connection.send_result(msg["id"])

View File

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

View File

@@ -49,7 +49,7 @@
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is off"
"name": "Light is off"
},
"is_on": {
"description": "Tests if one or more lights are on.",
@@ -59,7 +59,7 @@
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is on"
"name": "Light is on"
}
},
"device_automation": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
{
"domain": "minecraft_server",
"name": "Minecraft Server",
"codeowners": ["@elmurato", "@zachdeibert"],
"codeowners": ["@elmurato"],
"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.1.0"]
"requirements": ["mcstatus==12.0.6"]
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
"""Support for Ness D8X/D16X devices."""
from collections import namedtuple
import datetime
import logging
from typing import NamedTuple
from nessclient import ArmingMode, ArmingState, Client
import voluptuous as vol
@@ -25,12 +25,11 @@ 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: HassKey[Client] = HassKey(DOMAIN)
DATA_NESS = "ness_alarm"
CONF_DEVICE_PORT = "port"
CONF_INFER_ARMING_STATE = "infer_arming_state"
@@ -45,13 +44,7 @@ DEFAULT_INFER_ARMING_STATE = False
SIGNAL_ZONE_CHANGED = "ness_alarm.zone_changed"
SIGNAL_ARMING_STATE_CHANGED = "ness_alarm.arming_state_changed"
class ZoneChangedData(NamedTuple):
"""Data for a zone state change."""
zone_id: int
state: bool
ZoneChangedData = namedtuple("ZoneChangedData", ["zone_id", "state"]) # noqa: PYI024
DEFAULT_ZONE_TYPE = BinarySensorDeviceClass.MOTION
ZONE_SCHEMA = vol.Schema(

View File

@@ -33,14 +33,18 @@ async def async_setup_platform(
configured_zones = discovery_info[CONF_ZONES]
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 = []
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
)
for zone_config in configured_zones
)
devices.append(device)
async_add_entities(devices)
class NessZoneBinarySensor(BinarySensorEntity):
@@ -48,14 +52,12 @@ class NessZoneBinarySensor(BinarySensorEntity):
_attr_should_poll = False
def __init__(
self, zone_id: int, name: str, zone_type: BinarySensorDeviceClass
) -> None:
def __init__(self, zone_id, name, zone_type):
"""Initialize the binary_sensor."""
self._zone_id = zone_id
self._attr_name = name
self._attr_device_class = zone_type
self._attr_is_on = False
self._name = name
self._type = zone_type
self._state = 0
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@@ -65,9 +67,24 @@ 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) -> None:
def _handle_zone_change(self, data: ZoneChangedData):
"""Handle zone state update."""
if self._zone_id == data.zone_id:
self._attr_is_on = data.state
self._state = data.state
self.async_write_ha_state()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,6 @@ from homeassistant.core import (
SupportsResponse,
)
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
ServiceValidationError,
@@ -97,9 +96,6 @@ 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
@@ -183,9 +179,7 @@ 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:
@@ -251,7 +245,8 @@ 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:
raise ConfigEntryAuthFailed(err) from err
LOGGER.error("Invalid API key: %s", err)
return False
except openai.OpenAIError as err:
raise ConfigEntryNotReady(err) from err
@@ -264,7 +259,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
return True
async def async_unload_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload OpenAI."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -285,7 +280,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[OpenAIConfigEntry, bool]] = {}
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)

View File

@@ -10,6 +10,7 @@ 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
@@ -34,7 +35,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenAIConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from proxmoxer import AuthenticationError, ProxmoxAPI
@@ -11,7 +10,6 @@ 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,
@@ -20,29 +18,26 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
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.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .common import (
ProxmoxClient,
ResourceException,
call_api_container_vm,
parse_api_container_vm,
)
from .common import ProxmoxClient, 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,
@@ -50,10 +45,6 @@ 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(
@@ -93,154 +84,109 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Import the Proxmox configuration from YAML."""
if DOMAIN not in config:
return True
"""Set up the platform."""
hass.data.setdefault(DOMAIN, {})
hass.async_create_task(_async_setup(hass, config))
def build_client() -> ProxmoxAPI:
"""Build the Proxmox client connection."""
hass.data[PROXMOX_CLIENTS] = {}
return True
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
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
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
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",
},
)
hass.data[PROXMOX_CLIENTS][host] = proxmox_client
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)
await hass.async_add_executor_job(build_client)
coordinators: dict[
str, dict[str, dict[int, DataUpdateCoordinator[dict[str, Any] | None]]]
] = {}
entry.runtime_data = coordinators
hass.data[DOMAIN][COORDINATORS] = coordinators
host_name = entry.data[CONF_HOST]
coordinators[host_name] = {}
# Create a coordinator for each vm/container
for host_config in config[DOMAIN]:
host_name = host_config["host"]
coordinators[host_name] = {}
proxmox: ProxmoxAPI = proxmox_client.get_api_client()
proxmox_client = hass.data[PROXMOX_CLIENTS][host_name]
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)
# Skip invalid hosts
if proxmox_client is None:
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()
proxmox = proxmox_client.get_api_client()
node_coordinators[vm["vmid"]] = coordinator
for node_config in host_config["nodes"]:
node_name = node_config["node"]
node_coordinators = coordinators[host_name][node_name] = {}
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()
for vm_id in node_config["vms"]:
coordinator = create_coordinator_container_vm(
hass, proxmox, host_name, node_name, vm_id, TYPE_VM
)
node_coordinators[container["vmid"]] = coordinator
# Fetch initial data
await coordinator.async_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
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)
)
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(
def create_coordinator_container_vm(
hass: HomeAssistant,
entry: ProxmoxConfigEntry,
proxmox: ProxmoxAPI,
host_name: str,
node_name: str,
@@ -259,7 +205,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
@@ -268,14 +214,9 @@ def _create_coordinator_container_vm(
return DataUpdateCoordinator(
hass,
LOGGER,
config_entry=entry,
_LOGGER,
config_entry=None,
name=f"proxmox_coordinator_{host_name}_{node_name}_{vm_id}",
update_method=async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
async def async_unload_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -2,48 +2,55 @@
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 AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import ProxmoxConfigEntry
from .const import CONF_CONTAINERS, CONF_NODE, CONF_NODES, CONF_VMS
from .const import COORDINATORS, DOMAIN, PROXMOX_CLIENTS
from .entity import ProxmoxEntity
async def async_setup_entry(
async def async_setup_platform(
hass: HomeAssistant,
entry: ProxmoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up binary sensors."""
if discovery_info is None:
return
sensors = []
host_name = entry.data[CONF_HOST]
host_name_coordinators = entry.runtime_data[host_name]
for host_config in discovery_info["config"][DOMAIN]:
host_name = host_config["host"]
host_name_coordinators = hass.data[DOMAIN][COORDINATORS][host_name]
for node_config in entry.data[CONF_NODES]:
node_name = node_config[CONF_NODE]
if hass.data[PROXMOX_CLIENTS][host_name] is None:
continue
for dev_id in node_config[CONF_VMS] + node_config[CONF_CONTAINERS]:
coordinator = host_name_coordinators[node_name][dev_id]
for node_config in host_config["nodes"]:
node_name = node_config["node"]
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)
for dev_id in node_config["vms"] + node_config["containers"]:
coordinator = host_name_coordinators[node_name][dev_id]
async_add_entities(sensors)
# 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)
def create_binary_sensor(

View File

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

View File

@@ -1,12 +1,16 @@
"""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"
@@ -14,3 +18,5 @@ DEFAULT_VERIFY_SSL = True
TYPE_VM = 0
TYPE_CONTAINER = 1
UPDATE_INTERVAL = 60
_LOGGER = logging.getLogger(__package__)

View File

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

View File

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

View File

@@ -24,10 +24,10 @@ from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import DATA_QUIKSWITCH, DOMAIN
_LOGGER = logging.getLogger(__name__)
DOMAIN = "qwikswitch"
CONF_DIMMER_ADJUST = "dimmer_adjust"
CONF_BUTTON_EVENTS = "button_events"
CV_DIM_VALUE = vol.All(vol.Coerce(float), vol.Range(min=1, max=3))
@@ -96,7 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if not await qsusb.update_from_devices():
return False
hass.data[DATA_QUIKSWITCH] = qsusb
hass.data[DOMAIN] = 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[DATA_QUIKSWITCH].stop()
hass.data[DOMAIN].stop()
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_stop)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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