Compare commits

..

29 Commits

Author SHA1 Message Date
Paul Bottein
b522db1daf Use state selector for climate service mode fields (#166486) 2026-03-25 16:43:41 +01:00
Paul Bottein
338836cba2 Use state selector for light service fields (#166489) 2026-03-25 16:43:24 +01:00
Paul Bottein
f5e7605502 Use state selector for fan service fields (#166488) 2026-03-25 16:43:11 +01:00
Paul Bottein
22ddb18ce2 Use state selector for humidifier service fields (#166487) 2026-03-25 16:42:52 +01:00
crash0verride11
b541dc0a97 Add names for sound programs in Yamaha Musiccast (#166231)
Co-authored-by: crash0verride11 <3526616+crash0verride11@users.noreply.github.com>
Co-authored-by: jtjart <80978647+jtjart@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-25 16:42:48 +01:00
Mike Degatano
15d0a01833 Replace calls to ingress panels API with aiohasupervisor (#166400) 2026-03-25 16:42:32 +01:00
Abode Systems
71be2073eb Add measurement state class for Abode multi-sensor entities (#166431) 2026-03-25 16:42:06 +01:00
Ronald van der Meer
e6886fc562 Add binary sensors for PoolDose delay/pump status entities (#166485)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-25 16:37:31 +01:00
Joost Lekkerkerker
7f0f038bcd Add entities for stick vacuum cleaner to SmartThings (#166127) 2026-03-25 16:28:20 +01:00
Joost Lekkerkerker
686ab66a52 Add sensors for more game modes to Chess.com (#166331) 2026-03-25 16:27:58 +01:00
hanwg
7a4f953fa6 Add send_media_group action for Telegram bot (#160939)
Co-authored-by: Denis Shulyaka <Shulyaka@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-25 16:18:25 +01:00
Erwin Douna
cd0834bfbe Add storages to Proxmox (#166409) 2026-03-25 16:11:41 +01:00
AlCalzone
c598aa6964 Re-discover Z-Wave list sensors when metadata states change (#166271)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-25 16:10:25 +01:00
Willem-Jan van Rootselaar
5ef28932e5 Bump python-bsblan to 5.1.3 (#166479) 2026-03-25 15:51:37 +01:00
Erik Montnemery
f2eac87673 Fix handling of units in NumericThresholdSelector (#166475) 2026-03-25 15:41:17 +01:00
Michael
aeb920e8ef Add domain driven triggers to counter helper (#164545)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-25 15:40:15 +01:00
Petar Petrov
8540a27f0d Filter artificial zero values at UTC midnight from Forecast.Solar data (#166447) 2026-03-25 15:14:48 +01:00
jorgenvi
fe2d8a31b8 Add battery sensor to Roth Touchline SL integration (#166283)
Co-authored-by: Jørgen Vinne Iversen <jorgenvi@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 15:05:38 +01:00
Erwin Douna
f4efc929d6 Fix Proxmox offline node (#165986)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-25 15:04:31 +01:00
Eniot
15d7febffd feat(transmission): add session and cumulative stats sensors (#166134) 2026-03-25 14:44:47 +01:00
Andres Ruiz
0a8f5449f2 Add initial quality scale for waterfurnace (#165756)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-25 14:41:29 +01:00
Fredrik Mårtensson
d2179d9243 Bump tuya-device-handlers to 0.0.15 (#166477) 2026-03-25 14:40:02 +01:00
7eaves
bf1327e355 Fix Meter Pro CO2 not discoverable via BT proxies (#165173)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:38:52 +01:00
Erwin Douna
9afa827eab Add backups sensors to Proxmox (#166380) 2026-03-25 14:35:52 +01:00
Mike O'Driscoll
3ae6f8e7a0 Updates for Casper glow Integraiton - Add Buttons (#166083)
Signed-off-by: Mike O'Driscoll <mike@unusedbytes.ca>
2026-03-25 14:32:47 +01:00
Tom Matheussen
56962ff907 Update IQS to Bronze for Satel Integra (#166469) 2026-03-25 14:31:32 +01:00
Erwin Douna
719b9bdc3c Add snapshot button to Proxmox (#166462) 2026-03-25 14:27:43 +01:00
Renat Sibgatulin
bb1dc51a6b Add a missing regression test for airq config flow (#166473) 2026-03-25 14:25:18 +01:00
Nathan Spencer
abbbb7df13 Bump pylitterbot to 2025.2.0 and update Litter-Robot 3 test data to match underlying API data (#166350)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-25 14:11:58 +01:00
190 changed files with 6469 additions and 834 deletions

View File

@@ -12,6 +12,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
@@ -40,6 +41,7 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
device.temp_unit
],
@@ -48,12 +50,14 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda _: PERCENTAGE,
value_fn=lambda device: cast(float, device.humidity),
),
AbodeSensorDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
value_fn=lambda device: cast(float, device.lux),
),

View File

@@ -4,9 +4,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
# --- Number or entity selectors ---

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
.number_or_entity_co: &number_or_entity_co
required: false

View File

@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"incomplete_discovery": "The discovered air-Q device did not provide a device ID. Ensure the firmware is up to date."
},
"error": {

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_armed: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
armed: *trigger_common

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_idle: *condition_common
is_listening: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
idle: *trigger_common
listening: *trigger_common

View File

@@ -155,6 +155,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"assist_satellite",
"button",
"climate",
"counter",
"cover",
"device_tracker",
"door",

View File

@@ -8,9 +8,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
.number_or_entity: &number_or_entity
required: false

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.1.2"],
"requirements": ["python-bsblan==5.1.3"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LIGHT]
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:

View File

@@ -0,0 +1,73 @@
"""Casper Glow integration button platform."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pycasperglow import CasperGlow
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class CasperGlowButtonEntityDescription(ButtonEntityDescription):
"""Describe a Casper Glow button entity."""
press_fn: Callable[[CasperGlow], Awaitable[None]]
BUTTON_DESCRIPTIONS: tuple[CasperGlowButtonEntityDescription, ...] = (
CasperGlowButtonEntityDescription(
key="pause",
translation_key="pause",
press_fn=lambda device: device.pause(),
),
CasperGlowButtonEntityDescription(
key="resume",
translation_key="resume",
press_fn=lambda device: device.resume(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the button platform for Casper Glow."""
async_add_entities(
CasperGlowButton(entry.runtime_data, description)
for description in BUTTON_DESCRIPTIONS
)
class CasperGlowButton(CasperGlowEntity, ButtonEntity):
"""A Casper Glow button entity."""
entity_description: CasperGlowButtonEntityDescription
def __init__(
self,
coordinator: CasperGlowCoordinator,
description: CasperGlowButtonEntityDescription,
) -> None:
"""Initialize a Casper Glow button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{format_mac(coordinator.device.address)}_{description.key}"
)
async def async_press(self) -> None:
"""Press the button."""
await self._async_command(self.entity_description.press_fn(self._device))

View File

@@ -4,6 +4,14 @@
"paused": {
"default": "mdi:timer-pause"
}
},
"button": {
"pause": {
"default": "mdi:pause"
},
"resume": {
"default": "mdi:play"
}
}
}
}

View File

@@ -14,6 +14,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pycasperglow==1.1.0"]
}

View File

@@ -32,7 +32,9 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow:
status: exempt
comment: Bluetooth device with no authentication credentials.
test-coverage: done
# Gold
@@ -53,15 +55,9 @@ rules:
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations:
status: exempt
comment: No entity translations needed.
exception-translations:
status: exempt
comment: No custom services that raise exceptions.
icon-translations:
status: exempt
comment: No icon translations needed.
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

View File

@@ -31,6 +31,14 @@
"paused": {
"name": "Dimming paused"
}
},
"button": {
"pause": {
"name": "Pause dimming"
},
"resume": {
"name": "Resume dimming"
}
}
},
"exceptions": {

View File

@@ -1,20 +1,68 @@
{
"entity": {
"sensor": {
"chess960_daily_draw": {
"default": "mdi:chess-pawn"
},
"chess960_daily_lost": {
"default": "mdi:chess-pawn"
},
"chess960_daily_rating": {
"default": "mdi:chart-line"
},
"chess960_daily_won": {
"default": "mdi:chess-pawn"
},
"chess_blitz_draw": {
"default": "mdi:chess-pawn"
},
"chess_blitz_lost": {
"default": "mdi:chess-pawn"
},
"chess_blitz_rating": {
"default": "mdi:chart-line"
},
"chess_blitz_won": {
"default": "mdi:chess-pawn"
},
"chess_bullet_draw": {
"default": "mdi:chess-pawn"
},
"chess_bullet_lost": {
"default": "mdi:chess-pawn"
},
"chess_bullet_rating": {
"default": "mdi:chart-line"
},
"chess_bullet_won": {
"default": "mdi:chess-pawn"
},
"chess_daily_draw": {
"default": "mdi:chess-pawn"
},
"chess_daily_lost": {
"default": "mdi:chess-pawn"
},
"chess_daily_rating": {
"default": "mdi:chart-line"
},
"chess_daily_won": {
"default": "mdi:chess-pawn"
},
"chess_rapid_draw": {
"default": "mdi:chess-pawn"
},
"chess_rapid_lost": {
"default": "mdi:chess-pawn"
},
"chess_rapid_rating": {
"default": "mdi:chart-line"
},
"chess_rapid_won": {
"default": "mdi:chess-pawn"
},
"followers": {
"default": "mdi:account-multiple"
},
"total_daily_draw": {
"default": "mdi:chess-pawn"
},
"total_daily_lost": {
"default": "mdi:chess-pawn"
},
"total_daily_won": {
"default": "mdi:chess-pawn"
}
}
}

View File

@@ -2,6 +2,9 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from chess_com_api import PlayerStats
from homeassistant.components.sensor import (
SensorEntity,
@@ -24,7 +27,14 @@ class ChessEntityDescription(SensorEntityDescription):
value_fn: Callable[[ChessData], float]
SENSORS: tuple[ChessEntityDescription, ...] = (
@dataclass(kw_only=True, frozen=True)
class ChessModeEntityDescription(SensorEntityDescription):
"""Sensor description for a Chess.com game mode."""
value_fn: Callable[[dict[str, Any]], float]
PLAYER_SENSORS: tuple[ChessEntityDescription, ...] = (
ChessEntityDescription(
key="followers",
translation_key="followers",
@@ -33,35 +43,46 @@ SENSORS: tuple[ChessEntityDescription, ...] = (
value_fn=lambda state: state.player.followers,
entity_registry_enabled_default=False,
),
ChessEntityDescription(
key="chess_daily_rating",
translation_key="chess_daily_rating",
)
GAME_MODE_SENSORS: tuple[ChessModeEntityDescription, ...] = (
ChessModeEntityDescription(
key="rating",
translation_key="rating",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda state: state.stats.chess_daily["last"]["rating"],
value_fn=lambda mode: mode["last"]["rating"],
),
ChessEntityDescription(
key="total_daily_won",
translation_key="total_daily_won",
ChessModeEntityDescription(
key="won",
translation_key="won",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda state: state.stats.chess_daily["record"]["win"],
value_fn=lambda mode: mode["record"]["win"],
),
ChessEntityDescription(
key="total_daily_lost",
translation_key="total_daily_lost",
ChessModeEntityDescription(
key="lost",
translation_key="lost",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda state: state.stats.chess_daily["record"]["loss"],
value_fn=lambda mode: mode["record"]["loss"],
),
ChessEntityDescription(
key="total_daily_draw",
translation_key="total_daily_draw",
ChessModeEntityDescription(
key="draw",
translation_key="draw",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda state: state.stats.chess_daily["record"]["draw"],
value_fn=lambda mode: mode["record"]["draw"],
),
)
GAME_MODES: dict[str, Callable[[PlayerStats], dict[str, Any] | None]] = {
"chess_daily": lambda stats: stats.chess_daily,
"chess_rapid": lambda stats: stats.chess_rapid,
"chess_bullet": lambda stats: stats.chess_bullet,
"chess_blitz": lambda stats: stats.chess_blitz,
"chess960_daily": lambda stats: stats.chess960_daily,
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -71,13 +92,22 @@ async def async_setup_entry(
"""Initialize the entries."""
coordinator = entry.runtime_data
async_add_entities(
ChessPlayerSensor(coordinator, description) for description in SENSORS
)
entities: list[SensorEntity] = [
ChessPlayerSensor(coordinator, description) for description in PLAYER_SENSORS
]
for game_mode, stats_fn in GAME_MODES.items():
if stats_fn(coordinator.data.stats) is not None:
entities.extend(
ChessGameModeSensor(coordinator, description, game_mode, stats_fn)
for description in GAME_MODE_SENSORS
)
async_add_entities(entities)
class ChessPlayerSensor(ChessEntity, SensorEntity):
"""Chess.com sensor."""
"""Chess.com player sensor."""
entity_description: ChessEntityDescription
@@ -95,3 +125,33 @@ class ChessPlayerSensor(ChessEntity, SensorEntity):
def native_value(self) -> float:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
class ChessGameModeSensor(ChessEntity, SensorEntity):
"""Chess.com game mode sensor."""
entity_description: ChessModeEntityDescription
def __init__(
self,
coordinator: ChessCoordinator,
description: ChessModeEntityDescription,
game_mode: str,
stats_fn: Callable[[PlayerStats], dict[str, Any] | None],
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._stats_fn = stats_fn
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}.{game_mode}.{description.key}"
)
self._attr_translation_key = f"{game_mode}_{description.translation_key}"
@property
def native_value(self) -> float:
"""Return the state of the sensor."""
mode_data = self._stats_fn(self.coordinator.data.stats)
if TYPE_CHECKING:
assert mode_data is not None
return self.entity_description.value_fn(mode_data)

View File

@@ -23,24 +23,84 @@
},
"entity": {
"sensor": {
"chess960_daily_draw": {
"name": "Total daily Chess960 games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess960_daily_lost": {
"name": "Total daily Chess960 games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess960_daily_rating": {
"name": "Daily Chess960 rating"
},
"chess960_daily_won": {
"name": "Total daily Chess960 games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_blitz_draw": {
"name": "Total blitz chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_blitz_lost": {
"name": "Total blitz chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_blitz_rating": {
"name": "Blitz chess rating"
},
"chess_blitz_won": {
"name": "Total blitz chess games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_bullet_draw": {
"name": "Total bullet chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_bullet_lost": {
"name": "Total bullet chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_bullet_rating": {
"name": "Bullet chess rating"
},
"chess_bullet_won": {
"name": "Total bullet chess games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_daily_draw": {
"name": "Total daily chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_daily_lost": {
"name": "Total daily chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_daily_rating": {
"name": "Daily chess rating"
},
"chess_daily_won": {
"name": "Total daily chess games won",
"unit_of_measurement": "games"
},
"chess_rapid_draw": {
"name": "Total rapid chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_rapid_lost": {
"name": "Total rapid chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"chess_rapid_rating": {
"name": "Rapid chess rating"
},
"chess_rapid_won": {
"name": "Total rapid chess games won",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::chess_daily_won::unit_of_measurement%]"
},
"followers": {
"name": "Followers",
"unit_of_measurement": "followers"
},
"total_daily_draw": {
"name": "Total chess games drawn",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
},
"total_daily_lost": {
"name": "Total chess games lost",
"unit_of_measurement": "[%key:component::chess_com::entity::sensor::total_daily_won::unit_of_measurement%]"
},
"total_daily_won": {
"name": "Total chess games won",
"unit_of_measurement": "games"
}
}
}

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
.number_or_entity_humidity: &number_or_entity_humidity
required: false

View File

@@ -11,7 +11,8 @@ set_preset_mode:
required: true
example: "away"
selector:
text:
state:
attribute: preset_mode
set_temperature:
target:
@@ -55,16 +56,10 @@ set_temperature:
mode: box
hvac_mode:
selector:
select:
options:
- "off"
- "auto"
- "cool"
- "dry"
- "fan_only"
- "heat_cool"
- "heat"
translation_key: hvac_mode
state:
hide_states:
- unavailable
- unknown
set_humidity:
target:
entity:
@@ -91,7 +86,8 @@ set_fan_mode:
required: true
example: "low"
selector:
text:
state:
attribute: fan_mode
set_hvac_mode:
target:
@@ -115,7 +111,8 @@ set_swing_mode:
required: true
example: "on"
selector:
text:
state:
attribute: swing_mode
set_swing_horizontal_mode:
target:
@@ -128,7 +125,8 @@ set_swing_horizontal_mode:
required: true
example: "on"
selector:
text:
state:
attribute: swing_horizontal_mode
turn_on:
target:

View File

@@ -281,17 +281,6 @@
"any": "Any"
}
},
"hvac_mode": {
"options": {
"auto": "[%key:common::state::auto%]",
"cool": "Cool",
"dry": "Dry",
"fan_only": "Fan only",
"heat": "Heat",
"heat_cool": "Heat/cool",
"off": "[%key:common::state::off%]"
}
},
"number_or_entity": {
"choices": {
"entity": "Entity",

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
.number_or_entity_humidity: &number_or_entity_humidity
required: false

View File

@@ -12,5 +12,22 @@
"set_value": {
"service": "mdi:counter"
}
},
"triggers": {
"decremented": {
"trigger": "mdi:numeric-negative-1"
},
"incremented": {
"trigger": "mdi:numeric-positive-1"
},
"maximum_reached": {
"trigger": "mdi:sort-numeric-ascending-variant"
},
"minimum_reached": {
"trigger": "mdi:sort-numeric-descending-variant"
},
"reset": {
"trigger": "mdi:refresh"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted counters to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::counter::title%]",
@@ -25,6 +29,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"decrement": {
"description": "Decrements a counter by its step size.",
@@ -49,5 +62,45 @@
"name": "Set"
}
},
"title": "Counter"
"title": "Counter",
"triggers": {
"decremented": {
"description": "Triggers after one or more counters decrement.",
"name": "Counter decremented"
},
"incremented": {
"description": "Triggers after one or more counters increment.",
"name": "Counter incremented"
},
"maximum_reached": {
"description": "Triggers after one or more counters reach their maximum value.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
"name": "Counter reached maximum"
},
"minimum_reached": {
"description": "Triggers after one or more counters reach their minimum value.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
"name": "Counter reached minimum"
},
"reset": {
"description": "Triggers after one or more counters are reset.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
"name": "Counter reset"
}
}
}

View File

@@ -0,0 +1,113 @@
"""Provides triggers for counters."""
from homeassistant.const import (
CONF_MAXIMUM,
CONF_MINIMUM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import CONF_INITIAL, DOMAIN
def _is_integer_state(state: State) -> bool:
"""Return True if the state's value can be interpreted as an integer."""
try:
int(state.state)
except TypeError, ValueError:
return False
return True
class CounterBaseIntegerTrigger(EntityTriggerBase):
"""Base trigger for valid counter integer states."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is valid."""
return _is_integer_state(state)
class CounterDecrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is decremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) > int(to_state.state)
class CounterIncrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is incremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) < int(to_state.state)
class CounterValueBaseTrigger(EntityTriggerBase):
"""Base trigger for counter value changes."""
_domain_specs = {DOMAIN: DomainSpec()}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != to_state.state
class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
return False
return state.state == str(max_value)
class CounterMinReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its minimum value."""
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
return False
return state.state == str(min_value)
class CounterResetTrigger(CounterValueBaseTrigger):
"""Trigger for reset of counter entities."""
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
if (init_state := state.attributes.get(CONF_INITIAL)) is None:
return False
return state.state == str(init_state)
TRIGGERS: dict[str, type[Trigger]] = {
"decremented": CounterDecrementedTrigger,
"incremented": CounterIncrementedTrigger,
"maximum_reached": CounterMaxReachedTrigger,
"minimum_reached": CounterMinReachedTrigger,
"reset": CounterResetTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for counters."""
return TRIGGERS

View File

@@ -0,0 +1,27 @@
.trigger_common: &trigger_common
target:
entity:
domain: counter
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
incremented:
target:
entity:
domain: counter
decremented:
target:
entity:
domain: counter
maximum_reached: *trigger_common
minimum_reached: *trigger_common
reset: *trigger_common

View File

@@ -3,9 +3,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
awning_is_closed:
fields: *condition_common_fields

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
awning_closed:
fields: *trigger_common_fields

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_home: *condition_common
is_not_home: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
entered_home: *trigger_common
left_home: *trigger_common

View File

@@ -3,9 +3,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_closed:
fields: *condition_common_fields

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
closed:
fields: *trigger_common_fields

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -10,7 +10,8 @@ set_preset_mode:
required: true
example: "auto"
selector:
text:
state:
attribute: preset_mode
set_percentage:
target:
@@ -49,7 +50,8 @@ turn_on:
supported_features:
- fan.FanEntityFeature.PRESET_MODE
selector:
text:
state:
attribute: preset_mode
turn_off:
target:

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
turned_on: *trigger_common
turned_off: *trigger_common

View File

@@ -20,5 +20,7 @@ async def async_get_solar_forecast(
"wh_hours": {
timestamp.isoformat(): val
for timestamp, val in entry.runtime_data.data.wh_period.items()
if val != 0
or (timestamp.hour, timestamp.minute, timestamp.second) != (0, 0, 0)
}
}

View File

@@ -3,9 +3,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_closed:
fields: *condition_common_fields

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
closed:
fields: *trigger_common_fields

View File

@@ -3,9 +3,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_closed:
fields: *condition_common_fields

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
closed:
fields: *trigger_common_fields

View File

@@ -666,7 +666,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
# Init add-on ingress panels
panels_task = hass.async_create_task(
async_setup_addon_panel(hass, hassio), eager_start=True
async_setup_addon_panel(hass), eager_start=True
)
# Make sure to await the update_info task before

View File

@@ -2,24 +2,23 @@
from http import HTTPStatus
import logging
from typing import Any
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import IngressPanel
from aiohttp import web
from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_PANELS, ATTR_TITLE
from .handler import HassIO, HassioAPIError
from .handler import get_supervisor_client
_LOGGER = logging.getLogger(__name__)
async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None:
async def async_setup_addon_panel(hass: HomeAssistant) -> None:
"""Add-on Ingress Panel setup."""
hassio_addon_panel = HassIOAddonPanel(hass, hassio)
hassio_addon_panel = HassIOAddonPanel(hass)
hass.http.register_view(hassio_addon_panel)
# If panels are exists
@@ -28,11 +27,8 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None:
# Register available panels
for addon, data in panels.items():
if not data[ATTR_ENABLE]:
if not data.enable:
continue
# _register_panel never suspends and is only
# a coroutine because it would be a breaking change
# to make it a normal function
_register_panel(hass, addon, data)
@@ -42,23 +38,22 @@ class HassIOAddonPanel(HomeAssistantView):
name = "api:hassio_push:panel"
url = "/api/hassio_push/panel/{addon}"
def __init__(self, hass: HomeAssistant, hassio: HassIO) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize WebView."""
self.hass = hass
self.hassio = hassio
self.client = get_supervisor_client(hass)
async def post(self, request: web.Request, addon: str) -> web.Response:
"""Handle new add-on panel requests."""
panels = await self.get_panels()
# Panel exists for add-on slug
if addon not in panels or not panels[addon][ATTR_ENABLE]:
_LOGGER.error("Panel is not enable for %s", addon)
if addon not in panels or not panels[addon].enable:
_LOGGER.error("Panel is not enabled for %s", addon)
return web.Response(status=HTTPStatus.BAD_REQUEST)
data = panels[addon]
# Register panel
_register_panel(self.hass, addon, data)
_register_panel(self.hass, addon, panels[addon])
return web.Response()
async def delete(self, request: web.Request, addon: str) -> web.Response:
@@ -66,24 +61,23 @@ class HassIOAddonPanel(HomeAssistantView):
frontend.async_remove_panel(self.hass, addon)
return web.Response()
async def get_panels(self) -> dict:
async def get_panels(self) -> dict[str, IngressPanel]:
"""Return panels add-on info data."""
try:
data = await self.hassio.get_ingress_panels()
return data[ATTR_PANELS]
except HassioAPIError as err:
return await self.client.ingress.panels()
except SupervisorError as err:
_LOGGER.error("Can't read panel info: %s", err)
return {}
def _register_panel(hass: HomeAssistant, addon: str, data: dict[str, Any]):
"""Init coroutine to register the panel."""
def _register_panel(hass: HomeAssistant, addon: str, data: IngressPanel):
"""Helper to register the panel."""
frontend.async_register_built_in_panel(
hass,
"app",
frontend_url_path=addon,
sidebar_title=data[ATTR_TITLE],
sidebar_icon=data[ATTR_ICON],
require_admin=data[ATTR_ADMIN],
sidebar_title=data.title,
sidebar_icon=data.icon,
require_admin=data.admin,
config={"addon": addon},
)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from http import HTTPStatus
import logging
import os
@@ -28,21 +27,6 @@ class HassioAPIError(RuntimeError):
"""Return if a API trow a error."""
def api_data[**_P](
funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]],
) -> Callable[_P, Coroutine[Any, Any, Any]]:
"""Return data of an api."""
async def _wrapper(*argv: _P.args, **kwargs: _P.kwargs) -> Any:
"""Wrap function."""
data = await funct(*argv, **kwargs)
if data["result"] == "ok":
return data["data"]
raise HassioAPIError(data["message"])
return _wrapper
class HassIO:
"""Small API wrapper for Hass.io."""
@@ -64,14 +48,6 @@ class HassIO:
"""Return base url for Supervisor."""
return self._base_url
@api_data
def get_ingress_panels(self) -> Coroutine:
"""Return data for Add-on ingress panels.
This method returns a coroutine.
"""
return self.send_command("/ingress/panels", method="get")
async def send_command(
self,
command: str,

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
.number_or_entity: &number_or_entity
required: false

View File

@@ -11,7 +11,8 @@ set_mode:
required: true
example: "away"
selector:
text:
state:
attribute: mode
set_humidity:
target:

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
started_drying: *trigger_common
started_humidifying: *trigger_common

View File

@@ -34,8 +34,10 @@ is_value:
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
above: *number_or_entity
below: *number_or_entity

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false

View File

@@ -8,9 +8,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
.number_or_entity: &number_or_entity
required: false

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_docked: *condition_common
is_encountering_an_error: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
docked: *trigger_common
errored: *trigger_common

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -225,7 +225,8 @@ turn_on:
supported_features:
- light.LightEntityFeature.EFFECT
selector:
text:
state:
attribute: effect
advanced_fields:
collapsed: true
fields:

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
.number_or_entity: &number_or_entity
required: false

View File

@@ -16,5 +16,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "platinum",
"requirements": ["pylitterbot==2025.1.0"]
"requirements": ["pylitterbot==2025.2.0"]
}

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_jammed: *condition_common
is_locked: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
jammed: *trigger_common
locked: *trigger_common

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -7,6 +7,9 @@ stopped_playing:
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false

View File

@@ -3,9 +3,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_detected:
fields: *condition_common_fields

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
detected:
fields: *trigger_common_fields

View File

@@ -3,9 +3,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_detected:
fields: *condition_common_fields

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
detected:
fields: *trigger_common_fields

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_home: *condition_common
is_not_home: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
entered_home: *trigger_common
left_home: *trigger_common

View File

@@ -158,6 +158,24 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
BinarySensorEntityDescription(
key="circulation_pump_status",
translation_key="circulation_pump_status",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="power_on_delay_status",
translation_key="power_on_delay_status",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="flow_delay_status",
translation_key="flow_delay_status",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)

View File

@@ -85,6 +85,12 @@
"on": "mdi:thermometer-alert"
}
},
"circulation_pump_status": {
"default": "mdi:pump"
},
"flow_delay_status": {
"default": "mdi:timer-sand"
},
"flow_rate_alarm": {
"default": "mdi:autorenew",
"state": {
@@ -103,6 +109,9 @@
"on": "mdi:flask-empty"
}
},
"power_on_delay_status": {
"default": "mdi:timer-sand"
},
"pump_alarm": {
"default": "mdi:pump",
"state": {

View File

@@ -86,6 +86,12 @@
"alarm_water_too_hot": {
"name": "Water too hot"
},
"circulation_pump_status": {
"name": "Circulation pump"
},
"flow_delay_status": {
"name": "Flow delay"
},
"flow_rate_alarm": {
"name": "Flow rate"
},
@@ -95,6 +101,9 @@
"ph_level_alarm": {
"name": "pH tank level"
},
"power_on_delay_status": {
"name": "Power-on delay"
},
"pump_alarm": {
"name": "Recirculation"
},

View File

@@ -51,9 +51,11 @@ is_value:
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
above: *number_or_entity_power
below: *number_or_entity_power
unit: *condition_unit_power

View File

@@ -3,9 +3,12 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: trigger_behavior
mode: trigger
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false

View File

@@ -15,9 +15,21 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NODE_ONLINE, VM_CONTAINER_RUNNING
from .const import (
NODE_ONLINE,
STATUS_OK,
STORAGE_ACTIVE,
STORAGE_ENABLED,
STORAGE_SHARED,
VM_CONTAINER_RUNNING,
)
from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
from .entity import (
ProxmoxContainerEntity,
ProxmoxNodeEntity,
ProxmoxStorageEntity,
ProxmoxVMEntity,
)
PARALLEL_UPDATES = 0
@@ -43,6 +55,13 @@ class ProxmoxNodeBinarySensorEntityDescription(BinarySensorEntityDescription):
state_fn: Callable[[ProxmoxNodeData], bool | None]
@dataclass(frozen=True, kw_only=True)
class ProxmoxStorageBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold Proxmox storage binary sensor description."""
state_fn: Callable[[dict[str, Any]], bool | None]
NODE_SENSORS: tuple[ProxmoxNodeBinarySensorEntityDescription, ...] = (
ProxmoxNodeBinarySensorEntityDescription(
key="status",
@@ -51,6 +70,15 @@ NODE_SENSORS: tuple[ProxmoxNodeBinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
),
ProxmoxNodeBinarySensorEntityDescription(
key="node_backup_status",
translation_key="node_backup_status",
state_fn=lambda data: bool(
data.backups and data.backups[0]["status"] != STATUS_OK
),
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
CONTAINER_SENSORS: tuple[ProxmoxContainerBinarySensorEntityDescription, ...] = (
@@ -73,6 +101,27 @@ VM_SENSORS: tuple[ProxmoxVMBinarySensorEntityDescription, ...] = (
),
)
STORAGE_SENSORS: tuple[ProxmoxStorageBinarySensorEntityDescription, ...] = (
ProxmoxStorageBinarySensorEntityDescription(
key="storage_active",
translation_key="storage_active",
state_fn=lambda data: data["active"] == STORAGE_ACTIVE,
entity_category=EntityCategory.DIAGNOSTIC,
),
ProxmoxStorageBinarySensorEntityDescription(
key="storage_enabled",
translation_key="storage_enabled",
state_fn=lambda data: data["enabled"] == STORAGE_ENABLED,
entity_category=EntityCategory.DIAGNOSTIC,
),
ProxmoxStorageBinarySensorEntityDescription(
key="storage_shared",
translation_key="storage_shared",
state_fn=lambda data: data["shared"] == STORAGE_SHARED,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -112,9 +161,22 @@ async def async_setup_entry(
for entity_description in CONTAINER_SENSORS
)
def _async_add_new_storages(
storages: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new storage binary sensors."""
async_add_entities(
ProxmoxStorageBinarySensor(
coordinator, entity_description, storage, node_data
)
for (node_data, storage) in storages
for entity_description in STORAGE_SENSORS
)
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
coordinator.new_vms_callbacks.append(_async_add_new_vms)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
coordinator.new_storages_callbacks.append(_async_add_new_storages)
_async_add_new_nodes(
[
@@ -139,6 +201,14 @@ async def async_setup_entry(
if (node_data.node["node"], vmid) in coordinator.known_containers
]
)
_async_add_new_storages(
[
(node_data, storage_data)
for node_data in coordinator.data.values()
for storage_id, storage_data in node_data.storages.items()
if (node_data.node["node"], storage_id) in coordinator.known_storages
]
)
class ProxmoxNodeBinarySensor(ProxmoxNodeEntity, BinarySensorEntity):
@@ -172,3 +242,14 @@ class ProxmoxContainerBinarySensor(ProxmoxContainerEntity, BinarySensorEntity):
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.state_fn(self.container_data)
class ProxmoxStorageBinarySensor(ProxmoxStorageEntity, BinarySensorEntity):
"""Representation of a Proxmox Storage binary sensor."""
entity_description: ProxmoxStorageBinarySensorEntityDescription
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.state_fn(self.storage_data)

View File

@@ -21,6 +21,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
@@ -141,6 +142,22 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="snapshot_create",
translation_key="snapshot_create",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node)
.qemu(vmid)
.snapshot.post(
name=(
"homeassistant_snapshot_"
f"{coordinator.data[node].vms[vmid]['name']}_"
f"{dt_util.utcnow().strftime('%Y%m%d%H%M%S')}"
)
)
),
entity_category=EntityCategory.CONFIG,
),
)
CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
@@ -168,6 +185,22 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
device_class=ButtonDeviceClass.RESTART,
),
ProxmoxContainerButtonEntityDescription(
key="snapshot_create",
translation_key="snapshot_create",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node)
.lxc(vmid)
.snapshot.post(
name=(
"homeassistant_snapshot_"
f"{coordinator.data[node].containers[vmid]['name']}_"
f"{dt_util.utcnow().strftime('%Y%m%d%H%M%S')}"
)
)
),
entity_category=EntityCategory.CONFIG,
),
)

View File

@@ -11,9 +11,16 @@ CONF_TOKEN_SECRET = "token_value"
CONF_VMS = "vms"
CONF_CONTAINERS = "containers"
CONF_USER = "user"
NODE_ONLINE = "online"
VM_CONTAINER_RUNNING = "running"
STORAGE_ACTIVE = 1
STORAGE_SHARED = 1
STORAGE_ENABLED = 1
STATUS_OK = "ok"
AUTH_PAM = "pam"
AUTH_PVE = "pve"
AUTH_OTHER = "other"

View File

@@ -37,6 +37,7 @@ from .const import (
CONF_TOKEN_SECRET,
DEFAULT_VERIFY_SSL,
DOMAIN,
NODE_ONLINE,
)
type ProxmoxConfigEntry = ConfigEntry[ProxmoxCoordinator]
@@ -53,6 +54,8 @@ class ProxmoxNodeData:
node: dict[str, Any] = field(default_factory=dict)
vms: dict[int, dict[str, Any]] = field(default_factory=dict)
containers: dict[int, dict[str, Any]] = field(default_factory=dict)
storages: dict[str, dict[str, Any]] = field(default_factory=dict)
backups: list[dict[str, Any]] = field(default_factory=list)
class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
@@ -78,6 +81,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
self.known_nodes: set[str] = set()
self.known_vms: set[tuple[str, int]] = set()
self.known_containers: set[tuple[str, int]] = set()
self.known_storages: set[tuple[str, str]] = set()
self.permissions: dict[str, dict[str, int]] = {}
self.new_nodes_callbacks: list[Callable[[list[ProxmoxNodeData]], None]] = []
@@ -87,6 +91,9 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
self.new_containers_callbacks: list[
Callable[[list[tuple[ProxmoxNodeData, dict[str, Any]]]], None]
] = []
self.new_storages_callbacks: list[
Callable[[list[tuple[ProxmoxNodeData, dict[str, Any]]]], None]
] = []
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -171,13 +178,17 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
) from err
data: dict[str, ProxmoxNodeData] = {}
for node, (vms, containers) in zip(nodes, vms_containers, strict=True):
for node, (vms, containers, storages, backups) in zip(
nodes, vms_containers, strict=True
):
data[node[CONF_NODE]] = ProxmoxNodeData(
node=node,
vms={int(vm["vmid"]): vm for vm in vms},
containers={
int(container["vmid"]): container for container in containers
},
storages={s["storage"]: s for s in storages},
backups=backups,
)
self._async_add_remove_nodes(data)
@@ -225,21 +236,47 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
def _fetch_all_nodes(
self,
) -> tuple[
list[dict[str, Any]], list[tuple[list[dict[str, Any]], list[dict[str, Any]]]]
list[dict[str, Any]],
list[
tuple[
list[dict[str, Any]],
list[dict[str, Any]],
list[dict[str, Any]],
list[dict[str, Any]],
]
],
]:
"""Fetch all nodes, and then proceed to the VMs and containers."""
"""Fetch all nodes, and then proceed to the VMs, containers, storages, and backups."""
nodes = self.proxmox.nodes.get() or []
vms_containers = [self._get_vms_containers(node) for node in nodes]
return nodes, vms_containers
node_data = [self._get_node_data(node) for node in nodes]
return nodes, node_data
def _get_vms_containers(
def _get_node_data(
self,
node: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Get vms and containers for a node."""
) -> tuple[
list[dict[str, Any]],
list[dict[str, Any]],
list[dict[str, Any]],
list[dict[str, Any]],
]:
"""Get vms, containers, storages, and backups for a node."""
if node.get("status") != NODE_ONLINE:
_LOGGER.debug(
"Node %s is offline, skipping VM/container/storage fetch",
node[CONF_NODE],
)
return [], [], [], []
vms = self.proxmox.nodes(node[CONF_NODE]).qemu.get() or []
containers = self.proxmox.nodes(node[CONF_NODE]).lxc.get() or []
return vms, containers
storages = self.proxmox.nodes(node[CONF_NODE]).storage.get() or []
backups = (
self.proxmox.nodes(node[CONF_NODE]).tasks.get(typefilter="vzdump", limit=1)
or []
)
return vms, containers, storages, backups
def _async_add_remove_nodes(self, data: dict[str, ProxmoxNodeData]) -> None:
"""Add new nodes/VMs/containers, track removals."""
@@ -288,6 +325,23 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
for containers_callback in self.new_containers_callbacks:
containers_callback(new_container_data)
current_storages = {
(node_name, storage_name)
for node_name, node_data in data.items()
for storage_name in node_data.storages
}
self.known_storages &= current_storages
new_storages = current_storages - self.known_storages
if new_storages:
_LOGGER.debug("New storages found: %s", new_storages)
self.known_storages.update(new_storages)
new_storage_data = [
(data[node_name], data[node_name].storages[storage_name])
for node_name, storage_name in new_storages
]
for storages_callback in self.new_storages_callbacks:
storages_callback(new_storage_data)
class ProxmoxSetupError(Exception):
"""Base exception for Proxmox setup issues."""

View File

@@ -10,9 +10,9 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from . import ProxmoxConfigEntry
from .const import CONF_TOKEN_SECRET
from .const import CONF_TOKEN_SECRET, CONF_USER
TO_REDACT = [CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_TOKEN_SECRET]
TO_REDACT = [CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_TOKEN_SECRET, CONF_USER]
async def async_get_config_entry_diagnostics(

View File

@@ -65,6 +65,59 @@ class ProxmoxNodeEntity(ProxmoxCoordinatorEntity):
return super().available and self.device_name in self.coordinator.data
class ProxmoxStorageEntity(ProxmoxCoordinatorEntity):
"""Represents a Storage entity."""
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: EntityDescription,
storage_data: dict[str, Any],
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox Storage entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._storage_data = storage_data
self._node_name = node_data.node["node"]
self.device_id = storage_data["storage"]
self.device_name = storage_data["storage"]
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
f"{coordinator.config_entry.entry_id}_storage_{self.device_id}",
)
},
name=f"Storage ({self.device_name})",
model="Storage",
configuration_url=_proxmox_base_url(coordinator).with_fragment(
f"v1:0:=storage/{self._node_name}/{storage_data['storage']}"
),
via_device=(
DOMAIN,
f"{coordinator.config_entry.entry_id}_node_{node_data.node['id']}",
),
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self._node_name}_{self.device_id}_{entity_description.key}"
@property
def available(self) -> bool:
"""Return if the device is available."""
return (
super().available
and self._node_name in self.coordinator.data
and self.device_id in self.coordinator.data[self._node_name].storages
)
@property
def storage_data(self) -> dict[str, Any]:
"""Return the Storage data."""
return self.coordinator.data[self._node_name].storages[self.device_id]
class ProxmoxVMEntity(ProxmoxCoordinatorEntity):
"""Represents a VM entity."""

View File

@@ -1,5 +1,25 @@
{
"entity": {
"binary_sensor": {
"storage_active": {
"default": "mdi:play",
"state": {
"off": "mdi:stop"
}
},
"storage_enabled": {
"default": "mdi:power",
"state": {
"off": "mdi:power-off"
}
},
"storage_shared": {
"default": "mdi:lan-connect",
"state": {
"off": "mdi:lan-disconnect"
}
}
},
"button": {
"hibernate": {
"default": "mdi:power-sleep"
@@ -10,6 +30,9 @@
"shutdown": {
"default": "mdi:power"
},
"snapshot_create": {
"default": "mdi:backup-restore"
},
"start": {
"default": "mdi:play"
},
@@ -81,6 +104,18 @@
"node_status": {
"default": "mdi:server"
},
"storage_available": {
"default": "mdi:harddisk"
},
"storage_total": {
"default": "mdi:harddisk"
},
"storage_used": {
"default": "mdi:harddisk"
},
"storage_used_percentage": {
"default": "mdi:harddisk"
},
"vm_cpu": {
"default": "mdi:cpu-64-bit"
},

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from homeassistant.components.sensor import (
@@ -17,9 +18,15 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import ProxmoxConfigEntry, ProxmoxNodeData
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
from .entity import (
ProxmoxContainerEntity,
ProxmoxNodeEntity,
ProxmoxStorageEntity,
ProxmoxVMEntity,
)
PARALLEL_UPDATES = 0
@@ -28,7 +35,7 @@ PARALLEL_UPDATES = 0
class ProxmoxNodeSensorEntityDescription(SensorEntityDescription):
"""Class to hold Proxmox node sensor description."""
value_fn: Callable[[ProxmoxNodeData], StateType]
value_fn: Callable[[ProxmoxNodeData], StateType | datetime]
@dataclass(frozen=True, kw_only=True)
@@ -45,6 +52,13 @@ class ProxmoxContainerSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[dict[str, Any]], StateType]
@dataclass(frozen=True, kw_only=True)
class ProxmoxStorageSensorEntityDescription(SensorEntityDescription):
"""Class to hold Proxmox storage sensor description."""
value_fn: Callable[[dict[str, Any]], StateType]
NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = (
ProxmoxNodeSensorEntityDescription(
key="node_cpu",
@@ -130,6 +144,31 @@ NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["online", "offline"],
),
ProxmoxNodeSensorEntityDescription(
key="node_backup_last_backup",
translation_key="node_backup_last_backup",
value_fn=lambda data: (
dt_util.utc_from_timestamp(data.backups[0]["endtime"])
if data.backups
else None
),
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
ProxmoxNodeSensorEntityDescription(
key="node_backup_duration",
translation_key="node_backup_duration",
value_fn=lambda data: (
data.backups[0]["endtime"] - data.backups[0]["starttime"]
if data.backups
else None
),
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
)
VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = (
@@ -350,6 +389,50 @@ CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = (
),
)
STORAGE_SENSORS: tuple[ProxmoxStorageSensorEntityDescription, ...] = (
ProxmoxStorageSensorEntityDescription(
key="storage_used",
translation_key="storage_used",
value_fn=lambda data: data["used"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxStorageSensorEntityDescription(
key="storage_total",
translation_key="storage_total",
value_fn=lambda data: data["total"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxStorageSensorEntityDescription(
key="storage_available",
translation_key="storage_available",
value_fn=lambda data: data["avail"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxStorageSensorEntityDescription(
key="storage_used_percentage",
translation_key="storage_used_percentage",
value_fn=lambda data: round(data["used_fraction"] * 100, 1),
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -389,9 +472,20 @@ async def async_setup_entry(
for entity_description in CONTAINER_SENSORS
)
def _async_add_new_storages(
storages: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new storage sensors."""
async_add_entities(
ProxmoxStorageSensor(coordinator, entity_description, storage, node_data)
for (node_data, storage) in storages
for entity_description in STORAGE_SENSORS
)
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
coordinator.new_vms_callbacks.append(_async_add_new_vms)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
coordinator.new_storages_callbacks.append(_async_add_new_storages)
_async_add_new_nodes(
[
@@ -416,6 +510,14 @@ async def async_setup_entry(
if (node_data.node["node"], vmid) in coordinator.known_containers
]
)
_async_add_new_storages(
[
(node_data, storage_data)
for node_data in coordinator.data.values()
for storage_name, storage_data in node_data.storages.items()
if (node_data.node["node"], storage_name) in coordinator.known_storages
]
)
class ProxmoxNodeSensor(ProxmoxNodeEntity, SensorEntity):
@@ -424,7 +526,7 @@ class ProxmoxNodeSensor(ProxmoxNodeEntity, SensorEntity):
entity_description: ProxmoxNodeSensorEntityDescription
@property
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.coordinator.data[self.device_name])
@@ -449,3 +551,14 @@ class ProxmoxContainerSensor(ProxmoxContainerEntity, SensorEntity):
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.container_data)
class ProxmoxStorageSensor(ProxmoxStorageEntity, SensorEntity):
"""Represents a Proxmox VE storage sensor."""
entity_description: ProxmoxStorageSensorEntityDescription
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.storage_data)

View File

@@ -105,8 +105,32 @@
},
"entity": {
"binary_sensor": {
"node_backup_status": {
"name": "Backup status"
},
"status": {
"name": "Status"
},
"storage_active": {
"name": "Storage active",
"state": {
"off": "Inactive",
"on": "Active"
}
},
"storage_enabled": {
"name": "Storage enabled",
"state": {
"off": "Disabled",
"on": "Enabled"
}
},
"storage_shared": {
"name": "Storage shared",
"state": {
"off": "Not shared",
"on": "Shared"
}
}
},
"button": {
@@ -119,6 +143,9 @@
"shutdown": {
"name": "Shutdown"
},
"snapshot_create": {
"name": "Create snapshot"
},
"start": {
"name": "Start"
},
@@ -174,6 +201,12 @@
"container_uptime": {
"name": "Uptime"
},
"node_backup_duration": {
"name": "Backup duration"
},
"node_backup_last_backup": {
"name": "Last backup"
},
"node_cpu": {
"name": "CPU usage"
},
@@ -205,6 +238,18 @@
"node_uptime": {
"name": "Uptime"
},
"storage_available": {
"name": "Available storage"
},
"storage_total": {
"name": "Total storage"
},
"storage_used": {
"name": "Used storage"
},
"storage_used_percentage": {
"name": "Storage usage percentage"
},
"vm_cpu": {
"name": "CPU usage"
},

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -7,5 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["satel_integra"],
"quality_scale": "bronze",
"requirements": ["satel-integra==1.0.0"]
}

View File

@@ -10,7 +10,7 @@ rules:
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: todo
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide any service actions.

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -7,9 +7,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -227,6 +227,25 @@ CAPABILITY_TO_SENSORS: dict[
entity_category=EntityCategory.DIAGNOSTIC,
)
},
Capability.SAMSUNG_CE_STICK_CLEANER_DUST_BAG: {
Attribute.STATUS: SmartThingsBinarySensorEntityDescription(
key=Attribute.STATUS,
is_on_key="full",
component_translation_key={
"station": "stick_cleaner_dust_bag",
},
device_class=BinarySensorDeviceClass.PROBLEM,
exists_fn=lambda component, _: component == "station",
)
},
Capability.SAMSUNG_CE_STICK_CLEANER_STICK_STATUS: {
Attribute.STATUS: SmartThingsBinarySensorEntityDescription(
key=Attribute.STATUS,
is_on_key="charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
)
},
}

View File

@@ -135,6 +135,14 @@ HEALTH_CONCERN = {
"hazardous": "hazardous",
}
STICK_CLEANER_STATUS = {
"ready": "ready",
"usingVacuum": "using_vacuum",
"emptyingDustbin": "emptying_dustbin",
"UVCleaning": "uv_cleaning",
"UVPaused": "uv_paused",
}
WASHER_OPTIONS = ["pause", "run", "stop"]
@@ -1239,6 +1247,39 @@ CAPABILITY_TO_SENSORS: dict[
)
],
},
Capability.SAMSUNG_CE_STICK_CLEANER_DUST_BAG: {
Attribute.USAGE: [
SmartThingsSensorEntityDescription(
key=Attribute.USAGE,
translation_key="stick_cleaner_dust_bag_usage",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
component_fn=lambda component: component == "station",
)
]
},
Capability.SAMSUNG_CE_STICK_CLEANER_STATUS: {
Attribute.OPERATING_STATE: [
SmartThingsSensorEntityDescription(
key=Attribute.OPERATING_STATE,
name=None,
translation_key="stick_cleaner_operating_state",
options=list(STICK_CLEANER_STATUS.values()),
device_class=SensorDeviceClass.ENUM,
)
]
},
Capability.SAMSUNG_CE_STICK_CLEANER_DUSTBIN_STATUS: {
Attribute.LAST_EMPTIED_TIME: [
SmartThingsSensorEntityDescription(
key=Attribute.LAST_EMPTIED_TIME,
translation_key="stick_cleaner_dustbin_last_emptied",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda value: dt_util.parse_datetime(value) if value else None,
)
]
},
}

View File

@@ -85,6 +85,9 @@
"robot_cleaner_dust_bag": {
"name": "Dust bag full"
},
"stick_cleaner_dust_bag": {
"name": "[%key:component::smartthings::entity::binary_sensor::robot_cleaner_dust_bag::name%]"
},
"stick_cleaner_status": {
"name": "Stick cleaner in station"
},
@@ -836,6 +839,22 @@
"tested": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::tested%]"
}
},
"stick_cleaner_dust_bag_usage": {
"name": "Dust bag cycles",
"unit_of_measurement": "cycles"
},
"stick_cleaner_dustbin_last_emptied": {
"name": "Last emptied"
},
"stick_cleaner_operating_state": {
"state": {
"emptying_dustbin": "Emptying dustbin",
"ready": "[%key:component::smartthings::entity::sensor::oven_machine_state::state::ready%]",
"using_vacuum": "Using vacuum",
"uv_cleaning": "UV cleaning",
"uv_paused": "UV paused"
}
},
"thermostat_cooling_setpoint": {
"name": "Cooling setpoint"
},
@@ -965,6 +984,9 @@
"dry_plus": {
"name": "Dry plus"
},
"empty_dustbin": {
"name": "Empty dustbin"
},
"heated_dry": {
"name": "Heated dry"
},

View File

@@ -188,6 +188,14 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio
on_command=Command.ENABLE_SOUND_DETECTION,
off_command=Command.DISABLE_SOUND_DETECTION,
),
Capability.SAMSUNG_CE_STICK_CLEANER_DUSTBIN_STATUS: SmartThingsSwitchEntityDescription(
key=Capability.SAMSUNG_CE_STICK_CLEANER_DUSTBIN_STATUS,
translation_key="empty_dustbin",
status_attribute=Attribute.OPERATING_STATE,
on_key="emptying",
on_command=Command.START_EMPTYING,
off_command=Command.STOP_EMPTYING,
),
}
DISHWASHER_WASHING_OPTIONS_TO_SWITCHES: dict[
Attribute | str, SmartThingsDishwasherWashingOptionSwitchEntityDescription

View File

@@ -7,9 +7,11 @@
required: true
default: any
selector:
automation_behavior:
select:
translation_key: condition_behavior
mode: condition
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -8,9 +8,12 @@
required: true
default: any
selector:
automation_behavior:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
mode: trigger
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -35,6 +35,7 @@ from .const import (
DOMAIN,
ENCRYPTED_MODELS,
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL,
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES,
SupportedModels,
)
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
@@ -261,7 +262,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
sensor_type: str = entry.data[CONF_SENSOR_TYPE]
switchbot_model = HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL[sensor_type]
# connectable means we can make connections to the device
connectable = switchbot_model in CONNECTABLE_SUPPORTED_MODEL_TYPES
connectable = (
switchbot_model in CONNECTABLE_SUPPORTED_MODEL_TYPES
and switchbot_model not in NON_CONNECTABLE_SUPPORTED_MODEL_TYPES
)
address: str = entry.data[CONF_ADDRESS]
await switchbot.close_stale_connections_by_address(address)

View File

@@ -113,8 +113,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
if (
not discovery_info.connectable
and model_name in CONNECTABLE_SUPPORTED_MODEL_TYPES
and model_name not in NON_CONNECTABLE_SUPPORTED_MODEL_TYPES
):
# Source is not connectable but the model is connectable
# Source is not connectable but the model is connectable only
return self.async_abort(reason="not_supported")
self._discovered_adv = parsed
data = parsed.data

View File

@@ -117,6 +117,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.METER: SupportedModels.HYGROMETER,
SwitchbotModel.IO_METER: SupportedModels.HYGROMETER,
SwitchbotModel.METER_PRO: SupportedModels.HYGROMETER,
SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2,
SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT,
SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
SwitchbotModel.PRESENCE_SENSOR: SupportedModels.PRESENCE_SENSOR,

View File

@@ -69,6 +69,7 @@ from .const import (
ATTR_IS_BIG,
ATTR_KEYBOARD,
ATTR_KEYBOARD_INLINE,
ATTR_MEDIA,
ATTR_MEDIA_TYPE,
ATTR_MESSAGE,
ATTR_MESSAGE_ID,
@@ -79,6 +80,7 @@ from .const import (
ATTR_OPTIONS,
ATTR_PARSER,
ATTR_PASSWORD,
ATTR_PROTECT_CONTENT,
ATTR_QUESTION,
ATTR_REACTION,
ATTR_REPLY_TO_MSGID,
@@ -125,6 +127,7 @@ from .const import (
SERVICE_SEND_CHAT_ACTION,
SERVICE_SEND_DOCUMENT,
SERVICE_SEND_LOCATION,
SERVICE_SEND_MEDIA_GROUP,
SERVICE_SEND_MESSAGE,
SERVICE_SEND_PHOTO,
SERVICE_SEND_POLL,
@@ -219,6 +222,43 @@ SERVICE_SCHEMA_SEND_FILE = vol.All(
SERVICE_SCHEMA_BASE_SEND_FILE,
)
SERVICE_SCHEMA_SEND_MEDIA_GROUP = vol.Schema(
{
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
vol.Required(ATTR_MEDIA): vol.All(
cv.ensure_list,
[
vol.Schema(
{
vol.Required(ATTR_MEDIA_TYPE): vol.In(
(
str(InputMediaType.AUDIO),
str(InputMediaType.VIDEO),
str(InputMediaType.DOCUMENT),
str(InputMediaType.PHOTO),
)
),
vol.Optional(ATTR_URL): cv.string,
vol.Optional(ATTR_FILE): cv.string,
vol.Optional(ATTR_CAPTION): cv.string,
vol.Optional(ATTR_USERNAME): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_AUTHENTICATION): cv.string,
vol.Optional(ATTR_VERIFY_SSL, default=True): cv.boolean,
}
)
],
vol.Length(min=2, max=10),
),
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean,
vol.Optional(ATTR_PROTECT_CONTENT): cv.boolean,
vol.Optional(ATTR_REPLY_TO_MSGID): vol.Coerce(int),
vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int),
}
)
SERVICE_SCHEMA_SEND_STICKER = vol.All(
cv.deprecated(ATTR_TIMEOUT),
@@ -386,6 +426,7 @@ SERVICE_MAP: dict[str, VolSchemaType] = {
SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE,
SERVICE_SEND_CHAT_ACTION: SERVICE_SCHEMA_SEND_CHAT_ACTION,
SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE,
SERVICE_SEND_MEDIA_GROUP: SERVICE_SCHEMA_SEND_MEDIA_GROUP,
SERVICE_SEND_STICKER: SERVICE_SCHEMA_SEND_STICKER,
SERVICE_SEND_ANIMATION: SERVICE_SCHEMA_SEND_FILE,
SERVICE_SEND_VIDEO: SERVICE_SCHEMA_SEND_FILE,
@@ -434,6 +475,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
SERVICE_SEND_MESSAGE,
SERVICE_SEND_CHAT_ACTION,
SERVICE_SEND_PHOTO,
SERVICE_SEND_MEDIA_GROUP,
SERVICE_SEND_ANIMATION,
SERVICE_SEND_VIDEO,
SERVICE_SEND_VOICE,
@@ -539,6 +581,10 @@ async def _call_service(
messages: dict[str, JsonValueType] | None = None
if service_name == SERVICE_SEND_MESSAGE:
messages = await notify_service.send_message(context=service.context, **kwargs)
elif service_name == SERVICE_SEND_MEDIA_GROUP:
messages = await notify_service.send_media_group(
context=service.context, **kwargs
)
elif service_name == SERVICE_SEND_CHAT_ACTION:
messages = await notify_service.send_chat_action(
context=service.context, **kwargs

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